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

@ -51,10 +51,10 @@ use domain::ports::{
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
WindowStateStore,
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler,
SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -74,17 +74,17 @@ use infrastructure::{
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore,
FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock,
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
pub mod dto;
@ -942,6 +942,8 @@ pub struct BackendCore {
pub close_ticket_assistant: Arc<CloseTicketAssistant>,
/// Shared MCP tool policy registry used by ticket assistant sessions.
pub tool_policy_registry: Arc<ToolPolicyRegistry>,
/// Durable per-agent MCP tool permission store.
pub mcp_tool_permission_store: Arc<dyn McpToolPermissionStore>,
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
/// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
/// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture.
@ -1505,6 +1507,10 @@ impl BackendCore {
// --- Project permissions (LP1) ---
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
let mcp_tool_permission_store =
Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port)));
let mcp_tool_permission_store_port =
Arc::clone(&mcp_tool_permission_store) as Arc<dyn McpToolPermissionStore>;
// --- Skill store (L12) ---
// Global skills live in the machine-local app-data dir; project skills are
@ -2438,6 +2444,7 @@ impl BackendCore {
open_ticket_assistant,
close_ticket_assistant,
tool_policy_registry,
mcp_tool_permission_store: mcp_tool_permission_store_port,
structured_sessions,
create_agent,
list_agents,
@ -2599,7 +2606,8 @@ impl BackendCore {
.with_events(events)
.with_ready_sink(ready_sink)
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
.with_tool_policies(Arc::clone(&self.tool_policy_registry)),
.with_tool_policies(Arc::clone(&self.tool_policy_registry))
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
endpoint,
listener,
project_id,

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,15 +330,16 @@ 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))
})
@ -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.

View File

@ -33,9 +33,9 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
ExitStatus, FileSystem, FsError, IdGenerator, McpToolPermissionStore, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -45,7 +45,10 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId};
use domain::{
AgentMcpToolPolicyOverride, AgentToolPolicy, IssueRef, McpToolPolicy,
ProjectMcpToolPermissions, PtySize, SessionId,
};
use uuid::Uuid;
use application::{
@ -53,6 +56,7 @@ use application::{
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
@ -274,6 +278,38 @@ impl FileSystem for FakeFs {
}
}
#[derive(Clone)]
struct FakeMcpToolPermissionStore {
doc: Arc<Mutex<ProjectMcpToolPermissions>>,
}
impl FakeMcpToolPermissionStore {
fn new(doc: ProjectMcpToolPermissions) -> Self {
Self {
doc: Arc::new(Mutex::new(doc)),
}
}
}
#[async_trait]
impl McpToolPermissionStore for FakeMcpToolPermissionStore {
async fn load_mcp_tool_permissions(
&self,
_project: &Project,
) -> Result<ProjectMcpToolPermissions, StoreError> {
Ok(self.doc.lock().unwrap().clone())
}
async fn save_mcp_tool_permissions(
&self,
_project: &Project,
permissions: &ProjectMcpToolPermissions,
) -> Result<(), StoreError> {
*self.doc.lock().unwrap() = permissions.clone();
Ok(())
}
}
#[derive(Clone)]
struct FakePty;
#[async_trait]
@ -433,6 +469,34 @@ fn server(service: Arc<OrchestratorService>) -> McpServer {
McpServer::new(service, project())
}
fn server_with_mcp_permissions(
service: Arc<OrchestratorService>,
permissions: ProjectMcpToolPermissions,
) -> McpServer {
McpServer::new(service, project())
.with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(permissions)))
}
fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions {
let known_tools = classified_tool_names();
ProjectMcpToolPermissions::new(
None,
vec![AgentMcpToolPolicyOverride::new(
agent,
McpToolPolicy::new(
allowed_tools
.iter()
.map(|tool| (*tool).to_owned())
.collect(),
&known_tools,
)
.unwrap(),
)],
&known_tools,
)
.unwrap()
}
/// A capturing event sink (the MCP twin of the file watcher's publish closure):
/// records every [`DomainEvent`] the server emits so a test can assert the
/// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via
@ -507,6 +571,11 @@ impl TicketToolProvider for FakeTicketTools {
) -> Result<Value, TicketToolError> {
self.calls.lock().unwrap().push(name.to_owned());
match name {
"idea_ticket_read" => Ok(json!({
"item": { "ref": "#7", "title": "Seeded ticket", "version": 1 }
})),
"idea_ticket_list" => Ok(json!({ "items": [] })),
"idea_ticket_read_carnet" => Ok(json!({ "ref": "#7", "carnet": "" })),
"idea_sprint_list" => Ok(json!({
"items": self.sprints.lock().unwrap().clone()
})),
@ -666,10 +735,232 @@ async fn requester_without_tool_policy_keeps_the_full_tools_list() {
assert!(names.len() > 3, "unfiltered requester got {names:?}");
}
#[tokio::test]
async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
let (service, _s) = build_service(FakeContexts::new());
let agent = AgentId::from_uuid(Uuid::from_u128(82));
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.for_requester(agent.to_string());
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "got error: {:?}", response.error);
let result = response.result.expect("result");
let names: Vec<&str> = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"idea_memory_read"));
assert!(names.contains(&"idea_ticket_list"));
assert!(!names.contains(&"idea_memory_write"));
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_ticket_update_carnet"));
assert!(!names.contains(&"idea_run_in_background"));
}
// ---------------------------------------------------------------------------
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let ticket_tools = Arc::new(FakeTicketTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(83));
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.with_ticket_tools(ticket_tools.clone())
.for_requester(agent.to_string());
for (id, tool, arguments) in [
(101, "idea_memory_read", json!({})),
(102, "idea_ticket_list", json!({})),
] {
let response = server
.handle_raw(&tools_call(id, tool, arguments))
.await
.expect("reply owed");
assert!(
response.error.is_none(),
"read tool {tool} must not be rejected by MCP policy: {:?}",
response.error
);
assert!(response.result.is_some(), "read tool {tool} should run");
}
for (id, tool, arguments) in [
(
111,
"idea_memory_write",
json!({ "slug": "note-a", "content": "body" }),
),
(
112,
"idea_ask_agent",
json!({ "target": "architect", "task": "do it" }),
),
(
113,
"idea_ticket_update_carnet",
json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
),
(
114,
"idea_run_in_background",
json!({ "label": "task", "command": "echo" }),
),
] {
let response = server
.handle_raw(&tools_call(id, tool, arguments))
.await
.expect("reply owed");
let error = response.error.expect("MCP policy rejection expected");
assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}");
assert!(
error.message.contains("not permitted"),
"message should be readable for {tool}; got {}",
error.message
);
assert!(response.result.is_none(), "denied {tool} must not run");
}
assert_eq!(
ticket_tools.calls(),
vec!["idea_ticket_list".to_owned()],
"denied ticket mutation must not reach the provider"
);
assert_eq!(ticket_tools.mutation_attempts(), 0);
}
#[tokio::test]
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.for_requester("mcp");
let response = server
.handle_raw(&tools_call(
121,
"idea_run_in_background",
json!({ "label": "task", "command": "echo" }),
))
.await
.expect("reply owed");
let error = response.error.expect("legacy requester must fail closed");
assert_eq!(error.code, error_codes::INVALID_PARAMS);
assert!(error.message.contains("not permitted"));
}
#[tokio::test]
async fn empty_requester_with_durable_store_is_read_only() {
let (service, _s) = build_service(FakeContexts::new());
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.for_requester("");
let response = server
.handle_raw(&tools_call(
122,
"idea_memory_write",
json!({ "slug": "note-a", "content": "body" }),
))
.await
.expect("reply owed");
let error = response.error.expect("empty requester must fail closed");
assert_eq!(error.code, error_codes::INVALID_PARAMS);
assert!(error.message.contains("not permitted"));
assert!(response.result.is_none());
}
#[tokio::test]
async fn durable_agent_override_allows_an_explicit_write_tool() {
let (service, _s) = build_service(FakeContexts::new());
let ticket_tools = Arc::new(FakeTicketTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(84));
let server =
server_with_mcp_permissions(service, allow_doc(agent, &["idea_ticket_update_carnet"]))
.with_ticket_tools(ticket_tools.clone())
.for_requester(agent.to_string());
let response = server
.handle_raw(&tools_call(
131,
"idea_ticket_update_carnet",
json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
))
.await
.expect("reply owed");
assert!(
response.error.is_none(),
"durable override should pass MCP policy, got {:?}",
response.error
);
let result = response.result.expect("tool result");
assert_eq!(
result["isError"],
json!(true),
"fake provider reports execution error after policy passes"
);
assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]);
assert_eq!(ticket_tools.mutation_attempts(), 1);
}
#[tokio::test]
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
let (service, _s) = build_service(FakeContexts::new());
let registry = Arc::new(ToolPolicyRegistry::new());
registry.set(
"ticket-assistant:demo:7",
AgentToolPolicy::new(
vec!["idea_ticket_update_carnet".to_owned()],
Some(IssueRef::from_str("#7").unwrap()),
true,
),
);
let ticket_tools = Arc::new(FakeTicketTools::default());
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.with_ticket_tools(ticket_tools.clone())
.with_tool_policies(registry)
.for_requester("ticket-assistant:demo:7");
let rejected = server
.handle_raw(&tools_call(
141,
"idea_ticket_update_carnet",
json!({ "ref": "#8", "expectedVersion": 1, "carnet": "body" }),
))
.await
.expect("reply owed");
let error = rejected.error.expect("bound ticket rejection expected");
assert_eq!(error.code, error_codes::INVALID_PARAMS);
assert!(error.message.contains("#8"));
assert!(ticket_tools.calls().is_empty());
let allowed = server
.handle_raw(&tools_call(
142,
"idea_ticket_update_carnet",
json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
))
.await
.expect("reply owed");
assert!(
allowed.error.is_none(),
"bound ticket should pass policy, got {:?}",
allowed.error
);
assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]);
}
#[tokio::test]
async fn launch_agent_call_creates_and_launches_the_agent() {
let contexts = FakeContexts::new();