fix(ticket-assistant): édition de ticket via les tools MCP idea_ticket_* (#27)

L'assistant IA d'édition de ticket éditait les fichiers du ticket en direct,
hors de tout contrôle. Il passe désormais par les tools MCP idea_ticket_* :
préparation d'un environnement structuré dédié et policy d'enforcement scopée
au ticket courant, de sorte que l'assistant ne peut agir que sur son ticket
via la surface MCP plutôt que sur le système de fichiers.

Couvert par de nouveaux tests QA (mcp_server, assistant_context_store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 09:10:23 +02:00
parent e54ffae0c3
commit 8570adb8e0
10 changed files with 873 additions and 40 deletions

View File

@ -8,7 +8,8 @@ use application::{
};
use async_trait::async_trait;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan,
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
};
use domain::profile::StructuredAdapter;
use domain::{
@ -189,6 +190,35 @@ impl AssistantContextProvider for FakeAssistantContext {
}
}
#[derive(Default)]
struct FakeEnvironmentPreparer {
calls: Mutex<Vec<(IssueRef, String, PreparedContext)>>,
}
#[async_trait]
impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer {
async fn prepare_ticket_assistant(
&self,
_project: &Project,
issue_ref: IssueRef,
_profile: &AgentProfile,
prepared: &PreparedContext,
requester: &str,
) -> Result<StructuredSessionEnvironment, RuntimeError> {
self.calls
.lock()
.unwrap()
.push((issue_ref, requester.to_owned(), prepared.clone()));
Ok(StructuredSessionEnvironment {
cwd: ProjectPath::new("/tmp/app-data/assistant/tickets/1/7").unwrap(),
env: vec![(
"CODEX_HOME".to_owned(),
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned(),
)],
})
}
}
struct FakeSession {
id: SessionId,
shutdowns: Arc<Mutex<usize>>,
@ -216,7 +246,15 @@ impl AgentSession for FakeSession {
#[derive(Default)]
struct FakeFactory {
starts: Mutex<Vec<(PreparedContext, SessionPlan, Option<domain::SandboxPlan>)>>,
starts: Mutex<
Vec<(
PreparedContext,
ProjectPath,
SessionPlan,
Vec<(String, String)>,
Option<domain::SandboxPlan>,
)>,
>,
shutdowns: Arc<Mutex<usize>>,
}
@ -230,15 +268,18 @@ impl AgentSessionFactory for FakeFactory {
&self,
_profile: &AgentProfile,
ctx: &PreparedContext,
_cwd: &ProjectPath,
cwd: &ProjectPath,
session: &SessionPlan,
_env: &[(String, String)],
env: &[(String, String)],
sandbox: Option<&domain::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts
.lock()
.unwrap()
.push((ctx.clone(), session.clone(), sandbox.cloned()));
self.starts.lock().unwrap().push((
ctx.clone(),
cwd.clone(),
session.clone(),
env.to_vec(),
sandbox.cloned(),
));
Ok(Arc::new(FakeSession {
id: SessionId::new_random(),
shutdowns: Arc::clone(&self.shutdowns),
@ -284,6 +325,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
let policies = Arc::new(FakePolicies::default());
let events = Arc::new(SpyBus::default());
let context = Arc::new(FakeAssistantContext::default());
let environment = Arc::new(FakeEnvironmentPreparer::default());
let factory = Arc::new(FakeFactory::default());
let open = OpenTicketAssistant::new(
Arc::new(FakeIssues {
@ -293,6 +335,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
profile: profile(profile_id),
}),
context.clone(),
environment.clone(),
factory.clone(),
structured.clone(),
policies.clone(),
@ -310,17 +353,42 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
.unwrap();
assert!(structured.session(&output.session_id).is_some());
assert_eq!(
output.requester,
"ticket-assistant:00000000000000000000000000000001:7"
);
let policy = policies.get_policy(&output.requester).expect("policy set");
assert!(policy.permits("idea_ticket_read"));
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_update_status", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_update_priority", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_update_carnet", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_link", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_unlink", issue.reference()));
assert!(!policy.permits("idea_ticket_create"));
assert!(!policy.permits("idea_ticket_list"));
assert!(!policy.permits("idea_ask_agent"));
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
let prepared = context.prepared.lock().unwrap().clone().unwrap();
assert!(prepared.content.as_str().contains("#7"));
assert!(prepared.content.as_str().contains("Ticket description"));
assert!(prepared.content.as_str().contains("Ticket carnet"));
let environment_calls = environment.calls.lock().unwrap();
assert_eq!(environment_calls.len(), 1);
assert_eq!(environment_calls[0].0, issue.reference());
assert_eq!(environment_calls[0].1, output.requester);
drop(environment_calls);
let starts = factory.starts.lock().unwrap();
assert!(matches!(starts[0].1, SessionPlan::None));
assert!(starts[0].2.is_none());
assert_eq!(starts[0].1.as_str(), "/tmp/app-data/assistant/tickets/1/7");
assert!(matches!(starts[0].2, SessionPlan::None));
assert_eq!(
starts[0].3,
vec![(
"CODEX_HOME".to_owned(),
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned()
)]
);
assert!(starts[0].4.is_none());
drop(starts);
close