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:
@ -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>,
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user