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:
@ -1,11 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::McpRuntime;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{SessionPlan, StructuredSessionEnvironmentPreparer};
|
||||
use domain::profile::{McpCapability, McpConfigStrategy, McpTransport};
|
||||
use domain::{
|
||||
AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority,
|
||||
IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath,
|
||||
AgentProfile, AgentRuntime, AssistantContextProvider, ContextInjection, ContextInjectionPlan,
|
||||
FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, IssueStatus, MarkdownDoc,
|
||||
PreparedContext, ProfileId, Project, ProjectId, ProjectPath, RemotePath, RuntimeError,
|
||||
SpawnSpec,
|
||||
};
|
||||
use infrastructure::{
|
||||
FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
|
||||
};
|
||||
use infrastructure::{FsAssistantContextStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
@ -65,6 +73,55 @@ fn store(app_data_dir: String) -> FsAssistantContextStore {
|
||||
FsAssistantContextStore::new(fs, app_data_dir)
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for FakeRuntime {
|
||||
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn prepare_invocation(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
Ok(SpawnSpec {
|
||||
command: "assistant-cli".to_owned(),
|
||||
args: Vec::new(),
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: Some(ContextInjectionPlan::File {
|
||||
// Deliberately looks like the real ticket carnet path. The preparer
|
||||
// must still materialise it under the isolated assistant cwd, never
|
||||
// under the project root.
|
||||
target: ".ideai/tickets/7/carnet.md".to_owned(),
|
||||
}),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(9)),
|
||||
"Claude",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
McpTransport::Stdio,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_context_is_embedded_and_injects_the_ticket() {
|
||||
let tmp = TempDir::new();
|
||||
@ -108,3 +165,83 @@ async fn app_data_override_replaces_the_embedded_default() {
|
||||
assert!(!body.contains("You are IdeA's ticket editing assistant"));
|
||||
assert!(body.contains("- Ref: #8"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_data() {
|
||||
let tmp = TempDir::new();
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
let app_data_dir = tmp.app_data_dir();
|
||||
let project = project(tmp.project_root());
|
||||
let project_carnet = PathBuf::from(project.root.as_str())
|
||||
.join(".ideai")
|
||||
.join("tickets")
|
||||
.join("7")
|
||||
.join("carnet.md");
|
||||
let prepared = PreparedContext {
|
||||
content: MarkdownDoc::new("assistant-only context"),
|
||||
relative_path: "ticket-assistant.md".to_owned(),
|
||||
project_root: project.root.as_str().to_owned(),
|
||||
};
|
||||
let requester = "ticket-assistant:00000000000000000000000000000001:7".to_owned();
|
||||
let preparer = TicketAssistantEnvironmentPreparer::new(
|
||||
fs.clone(),
|
||||
app_data_dir.clone(),
|
||||
Arc::new(FakeRuntime),
|
||||
Arc::new(move |_, _| {
|
||||
Some(McpRuntime {
|
||||
exe: "/opt/idea/idea".to_owned(),
|
||||
endpoint: "127.0.0.1:4567".to_owned(),
|
||||
project_id: "00000000000000000000000000000001".to_owned(),
|
||||
requester: requester.clone(),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
let env = preparer
|
||||
.prepare_ticket_assistant(
|
||||
&project,
|
||||
issue(7).reference(),
|
||||
&mcp_profile(),
|
||||
&prepared,
|
||||
"ticket-assistant:00000000000000000000000000000001:7",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let run_dir = PathBuf::from(&app_data_dir)
|
||||
.join("assistant")
|
||||
.join("tickets")
|
||||
.join("00000000000000000000000000000001")
|
||||
.join("7");
|
||||
assert_eq!(env.cwd.as_str(), run_dir.to_string_lossy());
|
||||
|
||||
let isolated_carnet = run_dir
|
||||
.join(".ideai")
|
||||
.join("tickets")
|
||||
.join("7")
|
||||
.join("carnet.md");
|
||||
let isolated_mcp = run_dir.join(".mcp.json");
|
||||
assert_eq!(
|
||||
fs.read(&RemotePath::new(
|
||||
isolated_carnet.to_string_lossy().into_owned()
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"assistant-only context"
|
||||
);
|
||||
let mcp = String::from_utf8(
|
||||
fs.read(&RemotePath::new(
|
||||
isolated_mcp.to_string_lossy().into_owned(),
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(mcp.contains(r#""idea""#));
|
||||
assert!(mcp.contains(r#""--requester""#));
|
||||
assert!(mcp.contains("ticket-assistant:00000000000000000000000000000001:7"));
|
||||
assert!(
|
||||
!project_carnet.exists(),
|
||||
"assistant context must not be written into the real project ticket carnet"
|
||||
);
|
||||
}
|
||||
|
||||
@ -830,6 +830,129 @@ async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() {
|
||||
assert_eq!(ticket_tools.mutation_attempts(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider() {
|
||||
for (id, tool, arguments) in [
|
||||
(
|
||||
21,
|
||||
"idea_ticket_update_status",
|
||||
json!({ "ref": "#8", "expectedVersion": 1, "status": "closed" }),
|
||||
),
|
||||
(
|
||||
22,
|
||||
"idea_ticket_update_priority",
|
||||
json!({ "ref": "#8", "expectedVersion": 1, "priority": "high" }),
|
||||
),
|
||||
(
|
||||
23,
|
||||
"idea_ticket_link",
|
||||
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||
),
|
||||
(
|
||||
24,
|
||||
"idea_ticket_unlink",
|
||||
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||
),
|
||||
] {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec![tool.to_owned()],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||
let server = server(service)
|
||||
.with_ticket_tools(ticket_tools.clone())
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = tools_call(id, tool, arguments);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
let error = response.error.expect("policy rejection expected");
|
||||
assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}");
|
||||
assert!(
|
||||
error.message.contains("#8"),
|
||||
"message should name the rejected issue for {tool}; got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(
|
||||
ticket_tools.calls().is_empty(),
|
||||
"policy rejection for {tool} must happen before ticket provider dispatch"
|
||||
);
|
||||
assert_eq!(
|
||||
ticket_tools.mutation_attempts(),
|
||||
0,
|
||||
"no mutation attempt expected for rejected {tool}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provider() {
|
||||
for (id, tool, arguments) in [
|
||||
(
|
||||
31,
|
||||
"idea_ticket_update_status",
|
||||
json!({ "ref": "#7", "expectedVersion": 1, "status": "closed" }),
|
||||
),
|
||||
(
|
||||
32,
|
||||
"idea_ticket_update_priority",
|
||||
json!({ "ref": "#7", "expectedVersion": 1, "priority": "high" }),
|
||||
),
|
||||
(
|
||||
33,
|
||||
"idea_ticket_link",
|
||||
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||
),
|
||||
(
|
||||
34,
|
||||
"idea_ticket_unlink",
|
||||
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||
),
|
||||
] {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec![tool.to_owned()],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||
let server = server(service)
|
||||
.with_ticket_tools(ticket_tools.clone())
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = tools_call(id, tool, arguments);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"bound issue should pass policy for {tool}, got {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("tool result");
|
||||
assert_eq!(
|
||||
result["isError"],
|
||||
json!(true),
|
||||
"fake provider intentionally returns a tool error after policy passes"
|
||||
);
|
||||
assert_eq!(ticket_tools.calls(), vec![tool.to_owned()]);
|
||||
assert_eq!(
|
||||
ticket_tools.mutation_attempts(),
|
||||
1,
|
||||
"bound {tool} should reach the ticket provider exactly once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user