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:
@ -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,
|
||||
|
||||
@ -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,17 +330,18 @@ 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))
|
||||
})
|
||||
.map(|t| {
|
||||
json!({
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user