feat(mcp): synchronise les permissions MCP avec les tools réellement exposés
Les permissions déclarées ne reflétaient pas toujours les tools effectivement exposés aux agents (contexte requester/projet lié après coup). Ajoute ToolInvoker::tools_for_bound_context/tools_for_context pour exposer la liste effective au moment de l'injection dans une requête OpenAI-compatible, et propage le calcul dans la policy MCP, le serveur, la factory de session et l'adapter openai_compat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -13,7 +13,7 @@ use domain::ports::{
|
||||
AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker,
|
||||
ToolSpec,
|
||||
};
|
||||
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||
use serde_json::Value;
|
||||
|
||||
@ -62,6 +62,22 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker {
|
||||
.map_or_else(Vec::new, |inner| inner.tools())
|
||||
}
|
||||
|
||||
async fn tools_for_context(
|
||||
&self,
|
||||
project_root: &str,
|
||||
requester: &str,
|
||||
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("mutex sain")
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
|
||||
})?;
|
||||
inner.tools_for_context(project_root, requester).await
|
||||
}
|
||||
|
||||
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||
let inner = self
|
||||
.inner
|
||||
@ -102,14 +118,38 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
fn tools(&self) -> Vec<ToolSpec> {
|
||||
infrastructure::orchestrator::mcp::catalogue()
|
||||
.into_iter()
|
||||
.map(|tool| ToolSpec {
|
||||
name: tool.name.to_owned(),
|
||||
description: tool.description.to_owned(),
|
||||
input_schema: tool.input_schema,
|
||||
})
|
||||
.map(tool_def_to_spec)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn tools_for_context(
|
||||
&self,
|
||||
project_root: &str,
|
||||
requester: &str,
|
||||
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
|
||||
let ephemeral_policy = self.policies.get_policy(requester);
|
||||
let project = self.find_project(project_root).await?;
|
||||
let surface = infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
|
||||
Some(self.mcp_tool_permissions.as_ref()),
|
||||
&project,
|
||||
requester,
|
||||
ephemeral_policy.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
|
||||
})?;
|
||||
Ok(
|
||||
infrastructure::orchestrator::mcp::policy::effective_tool_catalogue(
|
||||
ephemeral_policy.as_ref(),
|
||||
&surface,
|
||||
)
|
||||
.into_iter()
|
||||
.map(tool_def_to_spec)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||
let value: Value = serde_json::from_str(args_json)
|
||||
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
|
||||
@ -140,18 +180,7 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
if let Some(policy) = &ephemeral_policy {
|
||||
enforce_policy(policy, &requester, name, &value)?;
|
||||
}
|
||||
let project = self
|
||||
.projects
|
||||
.list_projects()
|
||||
.await
|
||||
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
|
||||
.into_iter()
|
||||
.find(|project| project.root.as_str() == project_root)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution(format!(
|
||||
"projet introuvable pour root `{project_root}`"
|
||||
))
|
||||
})?;
|
||||
let project = self.find_project(&project_root).await?;
|
||||
enforce_durable_tool_policy(
|
||||
self.mcp_tool_permissions.as_ref(),
|
||||
&project,
|
||||
@ -209,6 +238,30 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
}
|
||||
}
|
||||
|
||||
impl AppOpenAiToolInvoker {
|
||||
async fn find_project(&self, project_root: &str) -> Result<Project, ToolInvocationError> {
|
||||
self.projects
|
||||
.list_projects()
|
||||
.await
|
||||
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
|
||||
.into_iter()
|
||||
.find(|project| project.root.as_str() == project_root)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution(format!(
|
||||
"projet introuvable pour root `{project_root}`"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_def_to_spec(tool: infrastructure::orchestrator::mcp::ToolDef) -> ToolSpec {
|
||||
ToolSpec {
|
||||
name: tool.name.to_owned(),
|
||||
description: tool.description.to_owned(),
|
||||
input_schema: tool.input_schema,
|
||||
}
|
||||
}
|
||||
|
||||
async fn enforce_durable_tool_policy(
|
||||
store: &dyn McpToolPermissionStore,
|
||||
project: &Project,
|
||||
@ -239,38 +292,17 @@ async fn durable_tool_policy(
|
||||
ephemeral_policy: Option<&AgentToolPolicy>,
|
||||
requester: &str,
|
||||
) -> Result<Option<McpToolPolicy>, ToolInvocationError> {
|
||||
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
|
||||
let policy = if let Some(agent_id) = requester_agent_id(requester) {
|
||||
let doc = store
|
||||
.load_mcp_tool_permissions(project)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}"))
|
||||
})?;
|
||||
doc.effective_policy(
|
||||
agent_id,
|
||||
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
|
||||
&known_tools,
|
||||
)
|
||||
.map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))?
|
||||
} else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
|
||||
McpToolPolicy::read_only(
|
||||
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
|
||||
&known_tools,
|
||||
)
|
||||
.map_err(|e| {
|
||||
ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}"))
|
||||
})?
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(policy))
|
||||
}
|
||||
|
||||
fn requester_agent_id(requester: &str) -> Option<AgentId> {
|
||||
uuid::Uuid::parse_str(requester)
|
||||
.ok()
|
||||
.map(AgentId::from_uuid)
|
||||
infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
|
||||
Some(store),
|
||||
project,
|
||||
requester,
|
||||
ephemeral_policy,
|
||||
)
|
||||
.await
|
||||
.map(|surface| surface.durable_policy)
|
||||
.map_err(|e| {
|
||||
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -796,6 +828,42 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_tools_list_matches_durable_agent_policy() {
|
||||
let temp =
|
||||
std::env::temp_dir().join(format!("idea-openai-mcp-tools-list-{}", Uuid::new_v4()));
|
||||
let core = crate::BackendCore::build(temp.clone());
|
||||
let agent = AgentId::from_uuid(Uuid::from_u128(182));
|
||||
let invoker = AppOpenAiToolInvoker::new(
|
||||
Arc::clone(&core.orchestrator_service),
|
||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||
Arc::new(FakePolicies::default()),
|
||||
mcp_permissions(allow_doc(
|
||||
agent,
|
||||
&["idea_ticket_list", "idea_ticket_update_carnet"],
|
||||
)),
|
||||
Arc::new(FakeTicketTools::default()),
|
||||
Arc::new(FakeTemplateTools::default()),
|
||||
);
|
||||
|
||||
let tools = invoker
|
||||
.tools_for_context("/tmp/project", &agent.to_string())
|
||||
.await
|
||||
.expect("tools list should resolve durable policy");
|
||||
let names = tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(names.contains(&"idea_ticket_list"));
|
||||
assert!(names.contains(&"idea_ticket_update_carnet"));
|
||||
assert!(!names.contains(&"idea_ask_agent"));
|
||||
assert!(!names.contains(&"idea_ticket_update"));
|
||||
assert!(!names.contains(&"idea_run_in_background"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_durable_agent_override_allows_explicit_write_tool() {
|
||||
let temp =
|
||||
|
||||
Reference in New Issue
Block a user