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

@ -2,7 +2,7 @@
use std::sync::Arc;
use domain::ports::{AgentSessionFactory, SessionPlan};
use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer};
use domain::AgentProfile;
use domain::{
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
@ -17,6 +17,7 @@ pub struct OpenTicketAssistant {
issues: Arc<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
@ -52,6 +53,7 @@ impl OpenTicketAssistant {
issues: Arc<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
@ -61,6 +63,7 @@ impl OpenTicketAssistant {
issues,
profiles,
contexts,
environment,
factory,
structured,
policies,
@ -94,39 +97,74 @@ impl OpenTicketAssistant {
.prepare_ticket_assistant_context(&input.project, &issue)
.await
.map_err(|e| AppError::Store(e.to_string()))?;
let requester = ticket_assistant_requester(&input.project, input.issue_ref);
let had_existing = self
.structured
.ticket_assistant_requester(input.issue_ref)
.is_some();
let policy = AgentToolPolicy::new(
vec![
"idea_ticket_read".to_owned(),
"idea_ticket_update".to_owned(),
"idea_ticket_update_status".to_owned(),
"idea_ticket_update_priority".to_owned(),
"idea_ticket_read_carnet".to_owned(),
"idea_ticket_update_carnet".to_owned(),
"idea_ticket_link".to_owned(),
"idea_ticket_unlink".to_owned(),
],
Some(input.issue_ref),
true,
);
self.policies.set_policy(requester.clone(), policy);
let environment = match self
.environment
.prepare_ticket_assistant(
&input.project,
input.issue_ref,
&profile,
&prepared,
&requester,
)
.await
{
Ok(environment) => environment,
Err(err) => {
if !had_existing {
self.policies.clear_policy(&requester);
}
return Err(AppError::from(err));
}
};
let session = self
.factory
.start(
&profile,
&prepared,
&input.project.root,
&environment.cwd,
&SessionPlan::None,
&[],
&environment.env,
None,
)
.await
.map_err(AppError::from)?;
.map_err(|err| {
if !had_existing {
self.policies.clear_policy(&requester);
}
AppError::from(err)
})?;
let session_id = session.id();
let requester = session_id.to_string();
let policy = AgentToolPolicy::new(
vec![
"idea_ticket_read".to_owned(),
"idea_ticket_update".to_owned(),
"idea_ticket_update_carnet".to_owned(),
],
Some(input.issue_ref),
true,
);
if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) {
self.policies.clear_policy(&old_requester);
if old_requester != requester {
self.policies.clear_policy(&old_requester);
}
}
let replaced = self.structured.insert_ticket_assistant(
input.issue_ref,
requester.clone(),
Arc::clone(&session),
);
self.policies.set_policy(requester.clone(), policy);
if let Some(old) = replaced {
let _ = old.shutdown().await;
}
@ -143,6 +181,14 @@ impl OpenTicketAssistant {
}
}
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {
format!(
"ticket-assistant:{}:{}",
project.id.as_uuid().simple(),
issue_ref.number().get()
)
}
/// Closes an ephemeral ticket assistant chat session.
pub struct CloseTicketAssistant {
structured: Arc<StructuredSessions>,

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