fix(#108): expose les tools MCP autorisées dès le démarrage de session Codex

This commit is contained in:
2026-07-29 18:19:38 +02:00
parent 2c3a46e690
commit af6b76935c
9 changed files with 251 additions and 89 deletions

View File

@ -31,82 +31,11 @@
"idea_template_create", "idea_template_create",
"idea_template_update", "idea_template_update",
"idea_template_delete", "idea_template_delete",
"idea_create_skill" "idea_create_skill",
"idea_ask_agents"
] ]
}, },
"agents": [ "agents": [
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"policy": {
"allowedTools": [
"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",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"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",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"policy": {
"allowedTools": [
"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",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"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",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{ {
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", "agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"policy": { "policy": {
@ -358,6 +287,80 @@
"idea_template_delete" "idea_template_delete"
] ]
} }
},
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"policy": {
"allowedTools": [
"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",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"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",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_run_in_background"
]
}
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"policy": {
"allowedTools": [
"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",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"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",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_run_in_background"
]
}
} }
] ]
} }

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#112" issueRef: "#112"
version: 3 version: 4
updatedBy: {"kind":"user"} updatedBy: {"kind":"user"}
updatedAt: 1785332227989 updatedAt: 1785341341049
--- ---

View File

@ -2,7 +2,7 @@
id: "ff8e11d1-98f8-4c6c-b5d0-a8a087c1dbbc" id: "ff8e11d1-98f8-4c6c-b5d0-a8a087c1dbbc"
number: 112 number: 112
title: "Pouvoir ajouter plusieurs tickets a la fois a un sprint" title: "Pouvoir ajouter plusieurs tickets a la fois a un sprint"
status: "open" status: "closed"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [] links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"user"}
createdAt: 1785332192429 createdAt: 1785332192429
updatedAt: 1785332227989 updatedAt: 1785341341049
version: 3 version: 4
--- ---
Je veux qu'on ajoute la possibilité de set le sprint des tickets selectionnés grace a la selection multiple de ticket dans la liste des tickets. Je veux qu'on ajoute la possibilité de set le sprint des tickets selectionnés grace a la selection multiple de ticket dans la liste des tickets.

View File

@ -1467,7 +1467,7 @@
"issueRef": "#112", "issueRef": "#112",
"path": "112", "path": "112",
"title": "Pouvoir ajouter plusieurs tickets a la fois a un sprint", "title": "Pouvoir ajouter plusieurs tickets a la fois a un sprint",
"status": "open", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
@ -1476,7 +1476,7 @@
"createdBy": { "createdBy": {
"kind": "user" "kind": "user"
}, },
"updatedAt": 1785332227989 "updatedAt": 1785341341049
} }
] ]
} }

View File

@ -597,6 +597,39 @@ mod tests {
.unwrap() .unwrap()
} }
fn default_and_override_doc(
agent: AgentId,
project_default_tools: &[&str],
override_tools: &[&str],
) -> ProjectMcpToolPermissions {
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
ProjectMcpToolPermissions::new(
Some(
McpToolPolicy::new(
project_default_tools
.iter()
.map(|tool| (*tool).to_owned())
.collect(),
&known_tools,
)
.unwrap(),
),
vec![AgentMcpToolPolicyOverride::new(
agent,
McpToolPolicy::new(
override_tools
.iter()
.map(|tool| (*tool).to_owned())
.collect(),
&known_tools,
)
.unwrap(),
)],
&known_tools,
)
.unwrap()
}
#[test] #[test]
fn openai_tool_policy_rejects_tool_outside_allowlist() { fn openai_tool_policy_rejects_tool_outside_allowlist() {
let policies = FakePolicies::default(); let policies = FakePolicies::default();
@ -864,6 +897,41 @@ mod tests {
let _ = std::fs::remove_dir_all(temp); let _ = std::fs::remove_dir_all(temp);
} }
#[tokio::test]
async fn openai_tools_list_keeps_project_default_tools_with_agent_override() {
let temp = std::env::temp_dir().join(format!(
"idea-openai-mcp-tools-list-override-{}",
Uuid::new_v4()
));
let core = crate::BackendCore::build(temp.clone());
let agent = AgentId::from_uuid(Uuid::from_u128(183));
let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()),
mcp_permissions(default_and_override_doc(
agent,
&["idea_memory_read", "idea_run_in_background"],
&["idea_memory_read"],
)),
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_eq!(names, vec!["idea_run_in_background", "idea_memory_read"]);
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test] #[tokio::test]
async fn openai_durable_agent_override_allows_explicit_write_tool() { async fn openai_durable_agent_override_allows_explicit_write_tool() {
let temp = let temp =

View File

@ -185,8 +185,8 @@ impl ProjectMcpToolPermissions {
/// Resolves the effective policy for `agent_id`. /// Resolves the effective policy for `agent_id`.
/// ///
/// Resolution order: agent override > project default > canonical read-only /// Resolution order: project default enriched by agent override > agent
/// fallback. /// override > project default > canonical read-only fallback.
/// ///
/// # Errors /// # Errors
/// [`McpToolPermissionError`] if the document or fallback set is invalid. /// [`McpToolPermissionError`] if the document or fallback set is invalid.
@ -197,12 +197,24 @@ impl ProjectMcpToolPermissions {
known_tools: &[&str], known_tools: &[&str],
) -> Result<McpToolPolicy, McpToolPermissionError> { ) -> Result<McpToolPolicy, McpToolPermissionError> {
self.validate(known_tools)?; self.validate(known_tools)?;
if let Some(override_) = self let override_policy = self
.agents .agents
.iter() .iter()
.find(|override_| override_.agent_id == agent_id) .find(|override_| override_.agent_id == agent_id)
.map(|override_| &override_.policy);
if let (Some(project_default), Some(override_policy)) =
(&self.project_default, override_policy)
{ {
return Ok(override_.policy.clone()); let mut allowed_tools = project_default.allowed_tools.clone();
for tool in &override_policy.allowed_tools {
if !allowed_tools.iter().any(|allowed| allowed == tool) {
allowed_tools.push(tool.clone());
}
}
return McpToolPolicy::new(allowed_tools, known_tools);
}
if let Some(override_policy) = override_policy {
return Ok(override_policy.clone());
} }
if let Some(policy) = &self.project_default { if let Some(policy) = &self.project_default {
return Ok(policy.clone()); return Ok(policy.clone());
@ -236,7 +248,7 @@ mod tests {
} }
#[test] #[test]
fn agent_override_replaces_project_default() { fn agent_override_extends_project_default() {
let project_default = let project_default =
McpToolPolicy::new(vec!["idea_memory_read".to_owned()], KNOWN).unwrap(); McpToolPolicy::new(vec!["idea_memory_read".to_owned()], KNOWN).unwrap();
let override_policy = let override_policy =
@ -250,8 +262,8 @@ mod tests {
let effective = doc.effective_policy(agent(7), READ_ONLY, KNOWN).unwrap(); let effective = doc.effective_policy(agent(7), READ_ONLY, KNOWN).unwrap();
assert!(effective.permits("idea_memory_read"));
assert!(effective.permits("idea_memory_write")); assert!(effective.permits("idea_memory_write"));
assert!(!effective.permits("idea_memory_read"));
} }
#[test] #[test]

View File

@ -700,11 +700,27 @@ mod tests {
#[derive(Default)] #[derive(Default)]
struct FakeInvoker { struct FakeInvoker {
calls: AtomicUsize, calls: AtomicUsize,
tools: Mutex<Vec<ToolSpec>>,
bound_tools: Mutex<Option<Vec<ToolSpec>>>,
}
impl FakeInvoker {
fn with_bound_tools(tools: Vec<ToolSpec>) -> Self {
Self {
calls: AtomicUsize::new(0),
tools: Mutex::new(Vec::new()),
bound_tools: Mutex::new(Some(tools)),
}
}
} }
#[async_trait] #[async_trait]
impl ToolInvoker for FakeInvoker { impl ToolInvoker for FakeInvoker {
fn tools(&self) -> Vec<ToolSpec> { fn tools(&self) -> Vec<ToolSpec> {
let tools = self.tools.lock().expect("mutex sain");
if !tools.is_empty() {
return tools.clone();
}
vec![ToolSpec { vec![ToolSpec {
name: "idea_echo".to_owned(), name: "idea_echo".to_owned(),
description: "Echo".to_owned(), description: "Echo".to_owned(),
@ -712,6 +728,15 @@ mod tests {
}] }]
} }
async fn tools_for_bound_context(&self) -> Result<Vec<ToolSpec>, ToolInvocationError> {
Ok(self
.bound_tools
.lock()
.expect("mutex sain")
.clone()
.unwrap_or_else(|| self.tools()))
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> { async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
self.calls.fetch_add(1, Ordering::SeqCst); self.calls.fetch_add(1, Ordering::SeqCst);
Ok(format!("{name}:{args_json}")) Ok(format!("{name}:{args_json}"))
@ -829,6 +854,18 @@ mod tests {
path path
} }
fn tool(name: &str) -> ToolSpec {
ToolSpec {
name: name.to_owned(),
description: format!("{name} description"),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
}
}
fn config(endpoint: String, max_tool_iterations: Option<u16>) -> HttpChatConfig { fn config(endpoint: String, max_tool_iterations: Option<u16>) -> HttpChatConfig {
config_with_timeouts(endpoint, max_tool_iterations, 10_000, 1_000) config_with_timeouts(endpoint, max_tool_iterations, 10_000, 1_000)
} }
@ -1055,6 +1092,44 @@ mod tests {
handle.abort(); handle.abort();
} }
#[tokio::test]
async fn first_openai_request_uses_bound_tool_surface() {
let (endpoint, bodies, handle) = http_server(vec![TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#,
)])
.await;
let invoker = Arc::new(FakeInvoker::with_bound_tools(vec![
tool("idea_memory_read"),
tool("idea_run_in_background"),
]));
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, Some(1)),
temp_run_dir("initial-bound-tools"),
"# system",
Some(invoker),
)
.expect("session");
let _ = drain(&session, "hello").await;
let bodies = bodies.lock().expect("mutex sain");
let body: Value = serde_json::from_str(&bodies[0]).expect("request body JSON");
let names = body["tools"]
.as_array()
.expect("initial request carries tools")
.iter()
.map(|tool| {
tool["function"]["name"]
.as_str()
.expect("tool function name")
.to_owned()
})
.collect::<Vec<_>>();
assert_eq!(names, vec!["idea_memory_read", "idea_run_in_background"]);
handle.abort();
}
#[tokio::test] #[tokio::test]
async fn max_tool_iterations_returns_single_degraded_final() { async fn max_tool_iterations_returns_single_degraded_final() {
let tool = r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{}"}}]}}]}"#; let tool = r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{}"}}]}}]}"#;

View File

@ -754,6 +754,9 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ticket_bulk_delete", "idea_ticket_bulk_delete",
"idea_ticket_read_carnet", "idea_ticket_read_carnet",
"idea_ticket_update_carnet", "idea_ticket_update_carnet",
"idea_ticket_attachment_add",
"idea_ticket_attachment_read",
"idea_ticket_attachment_mark_summarized",
"idea_ticket_link", "idea_ticket_link",
"idea_ticket_unlink", "idea_ticket_unlink",
"idea_sprint_list", "idea_sprint_list",
@ -772,8 +775,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply")); assert!(!names.contains(&"idea_reply"));
assert_eq!( assert_eq!(
tools.len(), tools.len(),
34, 37,
"exactly the thirty-four exposed idea_* tools; got {names:?}" "exactly the thirty-seven exposed idea_* tools; got {names:?}"
); );
// Every tool advertises an object input schema. // Every tool advertises an object input schema.

View File

@ -110,7 +110,8 @@ async fn save_then_load_roundtrips_project_default_and_agent_override() {
.effective_policy(agent, READ_ONLY_TOOLS, &classified_tool_names()) .effective_policy(agent, READ_ONLY_TOOLS, &classified_tool_names())
.unwrap(); .unwrap();
assert!(effective.permits("idea_ticket_update")); assert!(effective.permits("idea_ticket_update"));
assert!(!effective.permits("idea_context_read")); assert!(effective.permits("idea_context_read"));
assert!(effective.permits("idea_memory_read"));
let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json"); let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json");
assert!( assert!(