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,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
|
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||||
ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
|
PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler,
|
||||||
StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
|
SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker,
|
||||||
WindowStateStore,
|
WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -74,17 +74,17 @@ use infrastructure::{
|
|||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||||
FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||||
FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
|
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
||||||
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
||||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
||||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
|
PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock,
|
||||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@ -942,6 +942,8 @@ pub struct BackendCore {
|
|||||||
pub close_ticket_assistant: Arc<CloseTicketAssistant>,
|
pub close_ticket_assistant: Arc<CloseTicketAssistant>,
|
||||||
/// Shared MCP tool policy registry used by ticket assistant sessions.
|
/// Shared MCP tool policy registry used by ticket assistant sessions.
|
||||||
pub tool_policy_registry: Arc<ToolPolicyRegistry>,
|
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
|
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
|
||||||
/// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
|
/// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
|
||||||
/// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture.
|
/// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture.
|
||||||
@ -1505,6 +1507,10 @@ impl BackendCore {
|
|||||||
// --- Project permissions (LP1) ---
|
// --- Project permissions (LP1) ---
|
||||||
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
||||||
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
|
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) ---
|
// --- Skill store (L12) ---
|
||||||
// Global skills live in the machine-local app-data dir; project skills are
|
// Global skills live in the machine-local app-data dir; project skills are
|
||||||
@ -2438,6 +2444,7 @@ impl BackendCore {
|
|||||||
open_ticket_assistant,
|
open_ticket_assistant,
|
||||||
close_ticket_assistant,
|
close_ticket_assistant,
|
||||||
tool_policy_registry,
|
tool_policy_registry,
|
||||||
|
mcp_tool_permission_store: mcp_tool_permission_store_port,
|
||||||
structured_sessions,
|
structured_sessions,
|
||||||
create_agent,
|
create_agent,
|
||||||
list_agents,
|
list_agents,
|
||||||
@ -2599,7 +2606,8 @@ impl BackendCore {
|
|||||||
.with_events(events)
|
.with_events(events)
|
||||||
.with_ready_sink(ready_sink)
|
.with_ready_sink(ready_sink)
|
||||||
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
|
.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,
|
endpoint,
|
||||||
listener,
|
listener,
|
||||||
project_id,
|
project_id,
|
||||||
|
|||||||
@ -21,7 +21,10 @@ use std::sync::Arc;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use application::OrchestratorService;
|
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 serde_json::{json, Value};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@ -69,6 +72,9 @@ pub struct McpServer {
|
|||||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
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 {
|
impl McpServer {
|
||||||
@ -85,6 +91,7 @@ impl McpServer {
|
|||||||
ready_sink: None,
|
ready_sink: None,
|
||||||
ticket_tools: None,
|
ticket_tools: None,
|
||||||
tool_policies: None,
|
tool_policies: None,
|
||||||
|
mcp_tool_permissions: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,6 +130,16 @@ impl McpServer {
|
|||||||
self
|
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
|
/// 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).
|
/// 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(),
|
ready_sink: self.ready_sink.clone(),
|
||||||
ticket_tools: self.ticket_tools.clone(),
|
ticket_tools: self.ticket_tools.clone(),
|
||||||
tool_policies: self.tool_policies.clone(),
|
tool_policies: self.tool_policies.clone(),
|
||||||
|
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,7 +300,7 @@ impl McpServer {
|
|||||||
self.notify_ready();
|
self.notify_ready();
|
||||||
Ok(self.initialize_result())
|
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,
|
"tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await,
|
||||||
other => Err(JsonRpcError::new(
|
other => Err(JsonRpcError::new(
|
||||||
error_codes::METHOD_NOT_FOUND,
|
error_codes::METHOD_NOT_FOUND,
|
||||||
@ -312,17 +330,18 @@ impl McpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
||||||
fn tools_list_result(&self) -> Value {
|
async fn tools_list_result(&self) -> Result<Value, JsonRpcError> {
|
||||||
let policy = self
|
let ephemeral_policy = self.ephemeral_tool_policy();
|
||||||
.tool_policies
|
let durable_policy = self.durable_tool_policy().await?;
|
||||||
.as_ref()
|
|
||||||
.and_then(|registry| registry.get(&self.requester));
|
|
||||||
let tools: Vec<Value> = tools::catalogue()
|
let tools: Vec<Value> = tools::catalogue()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
policy
|
ephemeral_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(true, |policy| policy.permits(t.name))
|
.map_or(true, |policy| policy.permits(t.name))
|
||||||
|
&& durable_policy
|
||||||
|
.as_ref()
|
||||||
|
.map_or(true, |policy| policy.permits(t.name))
|
||||||
})
|
})
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
json!({
|
json!({
|
||||||
@ -332,7 +351,82 @@ impl McpServer {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.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
|
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
|
||||||
@ -346,13 +440,12 @@ impl McpServer {
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||||
|
|
||||||
if let Some(policy) = self
|
if let Some(policy) = self.ephemeral_tool_policy() {
|
||||||
.tool_policies
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|registry| registry.get(&self.requester))
|
|
||||||
{
|
|
||||||
self.enforce_tool_policy(&policy, &name, &arguments)?;
|
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
|
// 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.
|
// 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::markdown::MarkdownDoc;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
ExitStatus, FileSystem, FsError, IdGenerator, McpToolPermissionStore, OutputStream,
|
||||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
|
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
|
||||||
StoreError,
|
SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||||
@ -45,7 +45,10 @@ use domain::project::{Project, ProjectPath};
|
|||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::skill::{Skill, SkillScope};
|
use domain::skill::{Skill, SkillScope};
|
||||||
use domain::terminal::{SessionKind, TerminalSession};
|
use domain::terminal::{SessionKind, TerminalSession};
|
||||||
use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId};
|
use domain::{
|
||||||
|
AgentMcpToolPolicyOverride, AgentToolPolicy, IssueRef, McpToolPolicy,
|
||||||
|
ProjectMcpToolPermissions, PtySize, SessionId,
|
||||||
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
@ -53,6 +56,7 @@ use application::{
|
|||||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||||
};
|
};
|
||||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||||
|
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||||
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
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)]
|
#[derive(Clone)]
|
||||||
struct FakePty;
|
struct FakePty;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -433,6 +469,34 @@ fn server(service: Arc<OrchestratorService>) -> McpServer {
|
|||||||
McpServer::new(service, project())
|
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):
|
/// 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
|
/// records every [`DomainEvent`] the server emits so a test can assert the
|
||||||
/// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via
|
/// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via
|
||||||
@ -507,6 +571,11 @@ impl TicketToolProvider for FakeTicketTools {
|
|||||||
) -> Result<Value, TicketToolError> {
|
) -> Result<Value, TicketToolError> {
|
||||||
self.calls.lock().unwrap().push(name.to_owned());
|
self.calls.lock().unwrap().push(name.to_owned());
|
||||||
match name {
|
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!({
|
"idea_sprint_list" => Ok(json!({
|
||||||
"items": self.sprints.lock().unwrap().clone()
|
"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:?}");
|
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)
|
// 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]
|
#[tokio::test]
|
||||||
async fn launch_agent_call_creates_and_launches_the_agent() {
|
async fn launch_agent_call_creates_and_launches_the_agent() {
|
||||||
let contexts = FakeContexts::new();
|
let contexts = FakeContexts::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user