Merge feature/ticket82-mcp-tools-catalog-permissions into develop
Ticket #82 : catalogue et permissions des tools MCP, backend complet en 4 lots — B1 domaine/store durable, B2 enforcement au serveur MCP stdio, B3 parité enforcement sur le chemin OpenAI-compatible, B4 API Tauri pour la future UI de gestion. QA vert sur chaque lot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -19,12 +19,14 @@ use application::{
|
|||||||
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
||||||
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
|
||||||
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
||||||
|
UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use domain::ports::ModelServerRuntime;
|
use domain::ports::ModelServerRuntime;
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
@ -49,17 +51,19 @@ use crate::dto::{
|
|||||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
||||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||||
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
||||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||||
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||||
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
|
UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use crate::embedded_server::{
|
use crate::embedded_server::{
|
||||||
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
|
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
|
||||||
@ -544,6 +548,68 @@ pub async fn resolve_agent_permissions(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] on invalid project id, invalid stored policy, or store failure.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_mcp_tool_permissions(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProjectMcpToolPermissionsDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&project_id, &state).await?;
|
||||||
|
state
|
||||||
|
.read_mcp_tool_permissions
|
||||||
|
.execute(ReadMcpToolPermissionsInput { project })
|
||||||
|
.await
|
||||||
|
.map(ProjectMcpToolPermissionsDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `update_project_mcp_tool_permissions` — replace or remove project MCP tool defaults.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] on invalid project id, invalid tool policy, or store failure.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_project_mcp_tool_permissions(
|
||||||
|
request: UpdateProjectMcpToolPermissionsRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProjectMcpToolPermissionsDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
state
|
||||||
|
.update_project_mcp_tool_permissions
|
||||||
|
.execute(UpdateProjectMcpToolPermissionsInput {
|
||||||
|
project,
|
||||||
|
policy: request.policy,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(ProjectMcpToolPermissionsDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `update_agent_mcp_tool_permissions` — replace or remove one agent MCP tool override.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] on invalid ids, invalid tool policy, or store failure.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_agent_mcp_tool_permissions(
|
||||||
|
request: UpdateAgentMcpToolPermissionsRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProjectMcpToolPermissionsDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
|
state
|
||||||
|
.update_agent_mcp_tool_permissions
|
||||||
|
.execute(UpdateAgentMcpToolPermissionsInput {
|
||||||
|
project,
|
||||||
|
agent_id,
|
||||||
|
policy: request.policy,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(ProjectMcpToolPermissionsDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Terminals (L3)
|
// Terminals (L3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -176,6 +176,9 @@ pub fn run() {
|
|||||||
commands::update_project_permissions,
|
commands::update_project_permissions,
|
||||||
commands::update_agent_permissions,
|
commands::update_agent_permissions,
|
||||||
commands::resolve_agent_permissions,
|
commands::resolve_agent_permissions,
|
||||||
|
commands::get_mcp_tool_permissions,
|
||||||
|
commands::update_project_mcp_tool_permissions,
|
||||||
|
commands::update_agent_mcp_tool_permissions,
|
||||||
commands::open_terminal,
|
commands::open_terminal,
|
||||||
commands::write_terminal,
|
commands::write_terminal,
|
||||||
commands::resize_terminal,
|
commands::resize_terminal,
|
||||||
|
|||||||
@ -10,9 +10,10 @@ use std::sync::{Arc, Mutex};
|
|||||||
use application::OrchestratorService;
|
use application::OrchestratorService;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentToolPolicyStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec,
|
AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker,
|
||||||
|
ToolSpec,
|
||||||
};
|
};
|
||||||
use domain::{AgentToolPolicy, IssueRef};
|
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||||
use infrastructure::TicketToolProvider;
|
use infrastructure::TicketToolProvider;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ pub struct AppOpenAiToolInvoker {
|
|||||||
orchestrator: Arc<OrchestratorService>,
|
orchestrator: Arc<OrchestratorService>,
|
||||||
projects: Arc<dyn ProjectStore>,
|
projects: Arc<dyn ProjectStore>,
|
||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,12 +81,14 @@ impl AppOpenAiToolInvoker {
|
|||||||
orchestrator: Arc<OrchestratorService>,
|
orchestrator: Arc<OrchestratorService>,
|
||||||
projects: Arc<dyn ProjectStore>,
|
projects: Arc<dyn ProjectStore>,
|
||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
orchestrator,
|
orchestrator,
|
||||||
projects,
|
projects,
|
||||||
policies,
|
policies,
|
||||||
|
mcp_tool_permissions,
|
||||||
ticket_tools,
|
ticket_tools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,7 +133,10 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
"identité requester interne absente pour l'outil".to_owned(),
|
"identité requester interne absente pour l'outil".to_owned(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
enforce_tool_policy(self.policies.as_ref(), &requester, name, &value)?;
|
let ephemeral_policy = self.policies.get_policy(&requester);
|
||||||
|
if let Some(policy) = &ephemeral_policy {
|
||||||
|
enforce_policy(policy, &requester, name, &value)?;
|
||||||
|
}
|
||||||
let project = self
|
let project = self
|
||||||
.projects
|
.projects
|
||||||
.list_projects()
|
.list_projects()
|
||||||
@ -142,6 +149,14 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
"projet introuvable pour root `{project_root}`"
|
"projet introuvable pour root `{project_root}`"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
enforce_durable_tool_policy(
|
||||||
|
self.mcp_tool_permissions.as_ref(),
|
||||||
|
&project,
|
||||||
|
ephemeral_policy.as_ref(),
|
||||||
|
&requester,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) {
|
if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) {
|
||||||
let value = self
|
let value = self
|
||||||
.ticket_tools
|
.ticket_tools
|
||||||
@ -178,16 +193,68 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enforce_tool_policy(
|
async fn enforce_durable_tool_policy(
|
||||||
policies: &dyn AgentToolPolicyStore,
|
store: &dyn McpToolPermissionStore,
|
||||||
|
project: &Project,
|
||||||
|
ephemeral_policy: Option<&AgentToolPolicy>,
|
||||||
requester: &str,
|
requester: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
arguments: &Value,
|
|
||||||
) -> Result<(), ToolInvocationError> {
|
) -> Result<(), ToolInvocationError> {
|
||||||
let Some(policy) = policies.get_policy(requester) else {
|
let Some(policy) = durable_tool_policy(store, project, ephemeral_policy, requester).await?
|
||||||
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
enforce_policy(&policy, requester, name, arguments)
|
if policy.permits(name) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let requester = if requester.is_empty() {
|
||||||
|
"mcp"
|
||||||
|
} else {
|
||||||
|
requester
|
||||||
|
};
|
||||||
|
Err(ToolInvocationError::Rejected(format!(
|
||||||
|
"MCP tool `{name}` is not permitted for requester {requester}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn durable_tool_policy(
|
||||||
|
store: &dyn McpToolPermissionStore,
|
||||||
|
project: &Project,
|
||||||
|
ephemeral_policy: Option<&AgentToolPolicy>,
|
||||||
|
requester: &str,
|
||||||
|
) -> Result<Option<McpToolPolicy>, ToolInvocationError> {
|
||||||
|
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
|
||||||
|
let policy = if let Some(agent_id) = requester_agent_id(requester) {
|
||||||
|
let doc = store
|
||||||
|
.load_mcp_tool_permissions(project)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}"))
|
||||||
|
})?;
|
||||||
|
doc.effective_policy(
|
||||||
|
agent_id,
|
||||||
|
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
|
||||||
|
&known_tools,
|
||||||
|
)
|
||||||
|
.map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))?
|
||||||
|
} else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
|
||||||
|
McpToolPolicy::read_only(
|
||||||
|
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
|
||||||
|
&known_tools,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}"))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(policy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requester_agent_id(requester: &str) -> Option<AgentId> {
|
||||||
|
uuid::Uuid::parse_str(requester)
|
||||||
|
.ok()
|
||||||
|
.map(AgentId::from_uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enforce_policy(
|
fn enforce_policy(
|
||||||
@ -233,3 +300,360 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use domain::ports::{AgentToolPolicyStore, McpToolPermissionStore, ProjectStore};
|
||||||
|
use domain::{
|
||||||
|
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||||
|
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||||
|
};
|
||||||
|
use infrastructure::TicketToolError;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakePolicies(Mutex<HashMap<String, AgentToolPolicy>>);
|
||||||
|
|
||||||
|
impl AgentToolPolicyStore for FakePolicies {
|
||||||
|
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
|
||||||
|
self.0.lock().unwrap().insert(requester, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
|
||||||
|
self.0.lock().unwrap().get(requester).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_policy(&self, requester: &str) {
|
||||||
|
self.0.lock().unwrap().remove(requester);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeProjects {
|
||||||
|
projects: Mutex<Vec<Project>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProjects {
|
||||||
|
fn with(project: Project) -> Self {
|
||||||
|
Self {
|
||||||
|
projects: Mutex::new(vec![project]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProjectStore for FakeProjects {
|
||||||
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||||
|
Ok(self.projects.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||||
|
self.projects
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|project| project.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||||
|
self.projects.lock().unwrap().push(project.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||||
|
Ok(Workspace::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeMcpToolPermissions {
|
||||||
|
doc: Mutex<ProjectMcpToolPermissions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeMcpToolPermissions {
|
||||||
|
fn new(doc: ProjectMcpToolPermissions) -> Self {
|
||||||
|
Self {
|
||||||
|
doc: Mutex::new(doc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpToolPermissionStore for FakeMcpToolPermissions {
|
||||||
|
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(Default)]
|
||||||
|
struct FakeTicketTools {
|
||||||
|
calls: Mutex<Vec<(String, String, Value)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TicketToolProvider for FakeTicketTools {
|
||||||
|
async fn handle_ticket_tool(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
requester: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, TicketToolError> {
|
||||||
|
self.calls.lock().unwrap().push((
|
||||||
|
requester.to_owned(),
|
||||||
|
name.to_owned(),
|
||||||
|
arguments.clone(),
|
||||||
|
));
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"requester": requester,
|
||||||
|
"ref": arguments.get("ref").and_then(Value::as_str).unwrap_or_default(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||||
|
"demo",
|
||||||
|
ProjectPath::new("/tmp/project").unwrap(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_permissions(doc: ProjectMcpToolPermissions) -> Arc<dyn McpToolPermissionStore> {
|
||||||
|
Arc::new(FakeMcpToolPermissions::new(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions {
|
||||||
|
let known_tools = infrastructure::orchestrator::mcp::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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_ref(raw: &str) -> IssueRef {
|
||||||
|
IssueRef::from_str(raw).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_general_agent_without_durable_override_is_read_only() {
|
||||||
|
let temp = std::env::temp_dir().join(format!(
|
||||||
|
"idea-app-tauri-openai-mcp-permissions-readonly-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let core = backend::BackendCore::build(temp.clone());
|
||||||
|
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
Arc::new(FakePolicies::default()),
|
||||||
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
|
ticket_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
invoker
|
||||||
|
.call(
|
||||||
|
"idea_ticket_list",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("read tool must pass the durable read-only policy");
|
||||||
|
|
||||||
|
for (tool, arguments) in [
|
||||||
|
(
|
||||||
|
"idea_memory_write",
|
||||||
|
json!({ "slug": "note-a", "content": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_ask_agent",
|
||||||
|
json!({ "target": "architect", "task": "do it" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_run_in_background",
|
||||||
|
json!({ "label": "task", "command": "echo" }),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut payload = arguments.as_object().unwrap().clone();
|
||||||
|
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
|
||||||
|
payload.insert(REQUESTER_ARG.to_owned(), json!(requester.clone()));
|
||||||
|
let err = invoker
|
||||||
|
.call(tool, &Value::Object(payload).to_string())
|
||||||
|
.await
|
||||||
|
.expect_err("write tool must be rejected without durable override");
|
||||||
|
assert!(
|
||||||
|
matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)),
|
||||||
|
"expected readable rejection for {tool}, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1, "only the read ticket tool should run");
|
||||||
|
assert_eq!(calls[0].1, "idea_ticket_list");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_durable_agent_override_allows_explicit_write_tool() {
|
||||||
|
let temp = std::env::temp_dir().join(format!(
|
||||||
|
"idea-app-tauri-openai-mcp-permissions-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let core = backend::BackendCore::build(temp.clone());
|
||||||
|
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||||
|
let requester = agent.to_string();
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
Arc::new(FakePolicies::default()),
|
||||||
|
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
||||||
|
ticket_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = invoker
|
||||||
|
.call(
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"ref": "#7",
|
||||||
|
"expectedVersion": 1,
|
||||||
|
"carnet": "body",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("durable override should let the write tool reach the provider");
|
||||||
|
let result: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(result["requester"], requester);
|
||||||
|
assert_eq!(result["ref"], "#7");
|
||||||
|
|
||||||
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].1, "idea_ticket_update_carnet");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_ticket_assistant_policy_still_bounds_ticket() {
|
||||||
|
let temp = std::env::temp_dir().join(format!(
|
||||||
|
"idea-app-tauri-openai-ticket-policy-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let core = backend::BackendCore::build(temp.clone());
|
||||||
|
let requester = "ticket-assistant:00000000000000000000000000000001:7";
|
||||||
|
let policies = Arc::new(FakePolicies::default());
|
||||||
|
policies.set_policy(
|
||||||
|
requester.to_owned(),
|
||||||
|
AgentToolPolicy::new(
|
||||||
|
vec!["idea_ticket_update_carnet".to_owned()],
|
||||||
|
Some(issue_ref("#7")),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
policies,
|
||||||
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
|
ticket_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let denied = invoker
|
||||||
|
.call(
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"ref": "#8",
|
||||||
|
"expectedVersion": 1,
|
||||||
|
"carnet": "denied",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("out-of-scope ticket must be rejected");
|
||||||
|
assert!(matches!(denied, ToolInvocationError::Rejected(message) if message.contains("#8")));
|
||||||
|
assert!(
|
||||||
|
ticket_tools.calls.lock().unwrap().is_empty(),
|
||||||
|
"ticket policy rejection happens before TicketToolProvider dispatch"
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = invoker
|
||||||
|
.call(
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"ref": "#7",
|
||||||
|
"expectedVersion": 1,
|
||||||
|
"carnet": "allowed",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("bound ticket update allowed");
|
||||||
|
let allowed: Value = serde_json::from_str(&allowed).unwrap();
|
||||||
|
assert_eq!(allowed["requester"], requester);
|
||||||
|
assert_eq!(allowed["ref"], "#7");
|
||||||
|
|
||||||
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].0, requester);
|
||||||
|
assert_eq!(calls[0].1, "idea_ticket_update_carnet");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub mod git;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod mcp_tool_permissions;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
@ -105,6 +106,12 @@ pub use layout::{
|
|||||||
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
|
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
|
||||||
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||||
};
|
};
|
||||||
|
pub use mcp_tool_permissions::{
|
||||||
|
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,
|
||||||
|
ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions,
|
||||||
|
UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions,
|
||||||
|
UpdateProjectMcpToolPermissionsInput,
|
||||||
|
};
|
||||||
pub use memory::{
|
pub use memory::{
|
||||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||||
GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories,
|
GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories,
|
||||||
|
|||||||
402
crates/application/src/mcp_tool_permissions.rs
Normal file
402
crates/application/src/mcp_tool_permissions.rs
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
//! MCP tool permission use cases.
|
||||||
|
//!
|
||||||
|
//! These use cases expose the durable per-project MCP tool policy document while
|
||||||
|
//! keeping the application layer independent from the concrete MCP catalogue
|
||||||
|
//! adapter. The catalogue classification is injected by the composition root.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::McpToolPermissionStore;
|
||||||
|
use domain::{
|
||||||
|
AgentId, AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, Project,
|
||||||
|
ProjectMcpToolPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Read/write MCP tool catalogue classification used to validate policies.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct McpToolPermissionCatalogue {
|
||||||
|
/// Tools allowed by the default fallback policy.
|
||||||
|
pub read_only_tools: Vec<String>,
|
||||||
|
/// Tools denied by default because they write, act, or execute.
|
||||||
|
pub write_action_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpToolPermissionCatalogue {
|
||||||
|
/// Builds a catalogue from read-only and write/action tool names.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Invalid`] if a tool name is empty or appears twice.
|
||||||
|
pub fn new(
|
||||||
|
read_only_tools: Vec<String>,
|
||||||
|
write_action_tools: Vec<String>,
|
||||||
|
) -> Result<Self, AppError> {
|
||||||
|
let catalogue = Self {
|
||||||
|
read_only_tools,
|
||||||
|
write_action_tools,
|
||||||
|
};
|
||||||
|
catalogue.validate()?;
|
||||||
|
Ok(catalogue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all known tool names as borrowed strings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn known_tool_refs(&self) -> Vec<&str> {
|
||||||
|
self.read_only_tools
|
||||||
|
.iter()
|
||||||
|
.chain(self.write_action_tools.iter())
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), AppError> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for tool in self
|
||||||
|
.read_only_tools
|
||||||
|
.iter()
|
||||||
|
.chain(self.write_action_tools.iter())
|
||||||
|
{
|
||||||
|
if tool.is_empty() {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"MCP tool name cannot be empty".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !seen.insert(tool.as_str()) {
|
||||||
|
return Err(AppError::Invalid(format!("duplicate MCP tool: {tool}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads durable MCP tool permissions for a project.
|
||||||
|
pub struct ReadMcpToolPermissions {
|
||||||
|
store: Arc<dyn McpToolPermissionStore>,
|
||||||
|
catalogue: McpToolPermissionCatalogue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadMcpToolPermissions {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
store: Arc<dyn McpToolPermissionStore>,
|
||||||
|
catalogue: McpToolPermissionCatalogue,
|
||||||
|
) -> Self {
|
||||||
|
Self { store, catalogue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the read.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store or validation failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: ReadMcpToolPermissionsInput,
|
||||||
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
||||||
|
let permissions = self.store.load_mcp_tool_permissions(&input.project).await?;
|
||||||
|
validate_doc(&permissions, &self.catalogue)?;
|
||||||
|
Ok(ReadMcpToolPermissionsOutput {
|
||||||
|
catalogue: self.catalogue.clone(),
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`ReadMcpToolPermissions`].
|
||||||
|
pub struct ReadMcpToolPermissionsInput {
|
||||||
|
/// Target project.
|
||||||
|
pub project: Project,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output for MCP tool permission reads and writes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReadMcpToolPermissionsOutput {
|
||||||
|
/// Canonical catalogue classification.
|
||||||
|
pub catalogue: McpToolPermissionCatalogue,
|
||||||
|
/// Persisted permission document.
|
||||||
|
pub permissions: ProjectMcpToolPermissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the project-wide default MCP tool policy.
|
||||||
|
pub struct UpdateProjectMcpToolPermissions {
|
||||||
|
store: Arc<dyn McpToolPermissionStore>,
|
||||||
|
catalogue: McpToolPermissionCatalogue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateProjectMcpToolPermissions {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
store: Arc<dyn McpToolPermissionStore>,
|
||||||
|
catalogue: McpToolPermissionCatalogue,
|
||||||
|
) -> Self {
|
||||||
|
Self { store, catalogue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the mutation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store or validation failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: UpdateProjectMcpToolPermissionsInput,
|
||||||
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
||||||
|
validate_policy(input.policy.as_ref(), &self.catalogue)?;
|
||||||
|
let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?;
|
||||||
|
doc.project_default = input.policy;
|
||||||
|
validate_doc(&doc, &self.catalogue)?;
|
||||||
|
self.store
|
||||||
|
.save_mcp_tool_permissions(&input.project, &doc)
|
||||||
|
.await?;
|
||||||
|
Ok(ReadMcpToolPermissionsOutput {
|
||||||
|
catalogue: self.catalogue.clone(),
|
||||||
|
permissions: doc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`UpdateProjectMcpToolPermissions`].
|
||||||
|
pub struct UpdateProjectMcpToolPermissionsInput {
|
||||||
|
/// Target project.
|
||||||
|
pub project: Project,
|
||||||
|
/// New project default policy. `None` removes the project default.
|
||||||
|
pub policy: Option<McpToolPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces or removes one agent MCP tool policy override.
|
||||||
|
pub struct UpdateAgentMcpToolPermissions {
|
||||||
|
store: Arc<dyn McpToolPermissionStore>,
|
||||||
|
catalogue: McpToolPermissionCatalogue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateAgentMcpToolPermissions {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
store: Arc<dyn McpToolPermissionStore>,
|
||||||
|
catalogue: McpToolPermissionCatalogue,
|
||||||
|
) -> Self {
|
||||||
|
Self { store, catalogue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the mutation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store or validation failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: UpdateAgentMcpToolPermissionsInput,
|
||||||
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
||||||
|
validate_policy(input.policy.as_ref(), &self.catalogue)?;
|
||||||
|
let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?;
|
||||||
|
doc.agents
|
||||||
|
.retain(|override_| override_.agent_id != input.agent_id);
|
||||||
|
if let Some(policy) = input.policy {
|
||||||
|
doc.agents
|
||||||
|
.push(AgentMcpToolPolicyOverride::new(input.agent_id, policy));
|
||||||
|
}
|
||||||
|
validate_doc(&doc, &self.catalogue)?;
|
||||||
|
self.store
|
||||||
|
.save_mcp_tool_permissions(&input.project, &doc)
|
||||||
|
.await?;
|
||||||
|
Ok(ReadMcpToolPermissionsOutput {
|
||||||
|
catalogue: self.catalogue.clone(),
|
||||||
|
permissions: doc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`UpdateAgentMcpToolPermissions`].
|
||||||
|
pub struct UpdateAgentMcpToolPermissionsInput {
|
||||||
|
/// Target project.
|
||||||
|
pub project: Project,
|
||||||
|
/// Target agent.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// New override policy. `None` removes the override.
|
||||||
|
pub policy: Option<McpToolPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_policy(
|
||||||
|
policy: Option<&McpToolPolicy>,
|
||||||
|
catalogue: &McpToolPermissionCatalogue,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if let Some(policy) = policy {
|
||||||
|
policy
|
||||||
|
.validate(&catalogue.known_tool_refs())
|
||||||
|
.map_err(permission_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_doc(
|
||||||
|
doc: &ProjectMcpToolPermissions,
|
||||||
|
catalogue: &McpToolPermissionCatalogue,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
doc.validate(&catalogue.known_tool_refs())
|
||||||
|
.map_err(permission_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_error(error: McpToolPermissionError) -> AppError {
|
||||||
|
AppError::Invalid(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ids::ProjectId;
|
||||||
|
use domain::ports::StoreError;
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::remote::RemoteRef;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct FakeStore {
|
||||||
|
doc: Mutex<ProjectMcpToolPermissions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeStore {
|
||||||
|
fn new(doc: ProjectMcpToolPermissions) -> Self {
|
||||||
|
Self {
|
||||||
|
doc: Mutex::new(doc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpToolPermissionStore for FakeStore {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalogue() -> McpToolPermissionCatalogue {
|
||||||
|
McpToolPermissionCatalogue::new(
|
||||||
|
vec!["idea_memory_read".to_owned(), "idea_ticket_list".to_owned()],
|
||||||
|
vec!["idea_memory_write".to_owned(), "idea_ask_agent".to_owned()],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||||
|
"demo",
|
||||||
|
ProjectPath::new("/tmp/project").unwrap(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(doc: ProjectMcpToolPermissions) -> Arc<dyn McpToolPermissionStore> {
|
||||||
|
Arc::new(FakeStore::new(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_returns_classified_catalogue_and_current_document() {
|
||||||
|
let policy = McpToolPolicy::new(
|
||||||
|
vec!["idea_memory_write".to_owned()],
|
||||||
|
&catalogue().known_tool_refs(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let doc = ProjectMcpToolPermissions::new(
|
||||||
|
Some(policy),
|
||||||
|
Vec::new(),
|
||||||
|
&catalogue().known_tool_refs(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let use_case = ReadMcpToolPermissions::new(store(doc.clone()), catalogue());
|
||||||
|
|
||||||
|
let output = use_case
|
||||||
|
.execute(ReadMcpToolPermissionsInput { project: project() })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(output.permissions, doc);
|
||||||
|
assert_eq!(
|
||||||
|
output.catalogue.read_only_tools,
|
||||||
|
vec!["idea_memory_read", "idea_ticket_list"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output.catalogue.write_action_tools,
|
||||||
|
vec!["idea_memory_write", "idea_ask_agent"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_rejects_unknown_tool() {
|
||||||
|
let use_case = UpdateAgentMcpToolPermissions::new(
|
||||||
|
store(ProjectMcpToolPermissions::default()),
|
||||||
|
catalogue(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = use_case
|
||||||
|
.execute(UpdateAgentMcpToolPermissionsInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: agent(7),
|
||||||
|
policy: Some(McpToolPolicy {
|
||||||
|
allowed_tools: vec!["idea_unknown".to_owned()],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, AppError::Invalid(message) if message.contains("idea_unknown")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_agent_override_roundtrips_through_read() {
|
||||||
|
let shared = store(ProjectMcpToolPermissions::default());
|
||||||
|
let update = UpdateAgentMcpToolPermissions::new(Arc::clone(&shared), catalogue());
|
||||||
|
let read = ReadMcpToolPermissions::new(Arc::clone(&shared), catalogue());
|
||||||
|
let agent_id = agent(9);
|
||||||
|
|
||||||
|
update
|
||||||
|
.execute(UpdateAgentMcpToolPermissionsInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id,
|
||||||
|
policy: Some(
|
||||||
|
McpToolPolicy::new(
|
||||||
|
vec!["idea_memory_write".to_owned()],
|
||||||
|
&catalogue().known_tool_refs(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = read
|
||||||
|
.execute(ReadMcpToolPermissionsInput { project: project() })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(output.permissions.agents.len(), 1);
|
||||||
|
assert_eq!(output.permissions.agents[0].agent_id, agent_id);
|
||||||
|
assert_eq!(
|
||||||
|
output.permissions.agents[0].policy.allowed_tools,
|
||||||
|
vec!["idea_memory_write"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1479,9 +1479,12 @@ pub fn parse_profile_id(raw: &str) -> Result<ProfileId, ErrorDto> {
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput,
|
ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput,
|
||||||
ListAgentsOutput, ReadAgentContextOutput,
|
ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
|
||||||
|
ProjectPermissions, TerminalSession,
|
||||||
};
|
};
|
||||||
use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession};
|
|
||||||
|
|
||||||
/// An agent crossing the wire. [`Agent`] already serialises camelCase
|
/// An agent crossing the wire. [`Agent`] already serialises camelCase
|
||||||
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
|
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
|
||||||
@ -1564,6 +1567,44 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
|||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
||||||
|
|
||||||
|
/// Canonical MCP tool catalogue classification crossing the wire.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpToolCatalogueDto {
|
||||||
|
/// Tools allowed by the default read-only fallback.
|
||||||
|
pub read_only_tools: Vec<String>,
|
||||||
|
/// Tools treated as writing/action/execution tools.
|
||||||
|
pub write_action_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full MCP tool permission state crossing the wire.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProjectMcpToolPermissionsDto {
|
||||||
|
/// Document format version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Canonical catalogue classification used for validation and display.
|
||||||
|
pub catalogue: McpToolCatalogueDto,
|
||||||
|
/// Optional project-wide default MCP tool policy.
|
||||||
|
pub project_default: Option<McpToolPolicy>,
|
||||||
|
/// Per-agent overrides.
|
||||||
|
pub agents: Vec<AgentMcpToolPolicyOverride>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReadMcpToolPermissionsOutput> for ProjectMcpToolPermissionsDto {
|
||||||
|
fn from(out: ReadMcpToolPermissionsOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
version: out.permissions.version,
|
||||||
|
catalogue: McpToolCatalogueDto {
|
||||||
|
read_only_tools: out.catalogue.read_only_tools,
|
||||||
|
write_action_tools: out.catalogue.write_action_tools,
|
||||||
|
},
|
||||||
|
project_default: out.permissions.project_default,
|
||||||
|
agents: out.permissions.agents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for updating project default permissions.
|
/// Request DTO for updating project default permissions.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -1596,6 +1637,28 @@ pub struct ResolveAgentPermissionsRequestDto {
|
|||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request DTO for updating project default MCP tool permissions.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateProjectMcpToolPermissionsRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// New project MCP tool policy. `null` removes the default.
|
||||||
|
pub policy: Option<McpToolPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for updating one agent MCP tool permission override.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateAgentMcpToolPermissionsRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Target agent id.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// New agent MCP tool policy. `null` removes the override.
|
||||||
|
pub policy: Option<McpToolPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `update_project_context`.
|
/// Request DTO for `update_project_context`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -3493,3 +3556,59 @@ pub struct SpawnBackgroundCommandRequestDto {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub deadline_ms: Option<u64>,
|
pub deadline_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use application::McpToolPermissionCatalogue;
|
||||||
|
use domain::{AgentId, ProjectMcpToolPermissions};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_mcp_tool_permissions_dto_uses_stable_camel_case_contract() {
|
||||||
|
let known_tools = ["idea_ticket_read", "idea_ticket_update"];
|
||||||
|
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
||||||
|
let output = ReadMcpToolPermissionsOutput {
|
||||||
|
catalogue: McpToolPermissionCatalogue::new(
|
||||||
|
vec!["idea_ticket_read".to_owned()],
|
||||||
|
vec!["idea_ticket_update".to_owned()],
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
permissions: ProjectMcpToolPermissions {
|
||||||
|
version: 1,
|
||||||
|
project_default: Some(
|
||||||
|
McpToolPolicy::new(vec!["idea_ticket_read".to_owned()], &known_tools).unwrap(),
|
||||||
|
),
|
||||||
|
agents: vec![AgentMcpToolPolicyOverride::new(
|
||||||
|
agent_id,
|
||||||
|
McpToolPolicy::new(vec!["idea_ticket_update".to_owned()], &known_tools)
|
||||||
|
.unwrap(),
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(ProjectMcpToolPermissionsDto::from(output)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
json!({
|
||||||
|
"version": 1,
|
||||||
|
"catalogue": {
|
||||||
|
"readOnlyTools": ["idea_ticket_read"],
|
||||||
|
"writeActionTools": ["idea_ticket_update"]
|
||||||
|
},
|
||||||
|
"projectDefault": {
|
||||||
|
"allowedTools": ["idea_ticket_read"]
|
||||||
|
},
|
||||||
|
"agents": [{
|
||||||
|
"agentId": agent_id,
|
||||||
|
"policy": {
|
||||||
|
"allowedTools": ["idea_ticket_update"]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -28,20 +28,21 @@ use application::{
|
|||||||
ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects,
|
ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects,
|
||||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
|
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
|
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||||
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||||
SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext,
|
||||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||||
|
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
||||||
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
@ -51,10 +52,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 +75,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 +943,14 @@ 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>,
|
||||||
|
/// Read durable MCP tool permissions plus canonical catalogue classification.
|
||||||
|
pub read_mcp_tool_permissions: Arc<ReadMcpToolPermissions>,
|
||||||
|
/// Replace or clear the project-wide MCP tool policy.
|
||||||
|
pub update_project_mcp_tool_permissions: Arc<UpdateProjectMcpToolPermissions>,
|
||||||
|
/// Replace or clear one agent MCP tool policy override.
|
||||||
|
pub update_agent_mcp_tool_permissions: Arc<UpdateAgentMcpToolPermissions>,
|
||||||
/// 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 +1514,33 @@ 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>;
|
||||||
|
let mcp_tool_catalogue = McpToolPermissionCatalogue::new(
|
||||||
|
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS
|
||||||
|
.iter()
|
||||||
|
.map(|tool| (*tool).to_owned())
|
||||||
|
.collect(),
|
||||||
|
infrastructure::orchestrator::mcp::tools::WRITE_ACTION_TOOLS
|
||||||
|
.iter()
|
||||||
|
.map(|tool| (*tool).to_owned())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.expect("MCP tool catalogue classification is valid");
|
||||||
|
let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new(
|
||||||
|
Arc::clone(&mcp_tool_permission_store_port),
|
||||||
|
mcp_tool_catalogue.clone(),
|
||||||
|
));
|
||||||
|
let update_project_mcp_tool_permissions = Arc::new(UpdateProjectMcpToolPermissions::new(
|
||||||
|
Arc::clone(&mcp_tool_permission_store_port),
|
||||||
|
mcp_tool_catalogue.clone(),
|
||||||
|
));
|
||||||
|
let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new(
|
||||||
|
Arc::clone(&mcp_tool_permission_store_port),
|
||||||
|
mcp_tool_catalogue,
|
||||||
|
));
|
||||||
|
|
||||||
// --- 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
|
||||||
@ -2346,6 +2382,7 @@ impl BackendCore {
|
|||||||
Arc::clone(&orchestrator_service),
|
Arc::clone(&orchestrator_service),
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
||||||
|
Arc::clone(&mcp_tool_permission_store_port),
|
||||||
Arc::clone(&ticket_tool_provider),
|
Arc::clone(&ticket_tool_provider),
|
||||||
)) as Arc<dyn ToolInvoker>);
|
)) as Arc<dyn ToolInvoker>);
|
||||||
|
|
||||||
@ -2438,6 +2475,10 @@ 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,
|
||||||
|
read_mcp_tool_permissions,
|
||||||
|
update_project_mcp_tool_permissions,
|
||||||
|
update_agent_mcp_tool_permissions,
|
||||||
structured_sessions,
|
structured_sessions,
|
||||||
create_agent,
|
create_agent,
|
||||||
list_agents,
|
list_agents,
|
||||||
@ -2599,7 +2640,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,
|
||||||
|
|||||||
@ -10,9 +10,10 @@ use std::sync::{Arc, Mutex};
|
|||||||
use application::OrchestratorService;
|
use application::OrchestratorService;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentToolPolicyStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec,
|
AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker,
|
||||||
|
ToolSpec,
|
||||||
};
|
};
|
||||||
use domain::{AgentToolPolicy, IssueRef};
|
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||||
use infrastructure::TicketToolProvider;
|
use infrastructure::TicketToolProvider;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ pub struct AppOpenAiToolInvoker {
|
|||||||
orchestrator: Arc<OrchestratorService>,
|
orchestrator: Arc<OrchestratorService>,
|
||||||
projects: Arc<dyn ProjectStore>,
|
projects: Arc<dyn ProjectStore>,
|
||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,12 +81,14 @@ impl AppOpenAiToolInvoker {
|
|||||||
orchestrator: Arc<OrchestratorService>,
|
orchestrator: Arc<OrchestratorService>,
|
||||||
projects: Arc<dyn ProjectStore>,
|
projects: Arc<dyn ProjectStore>,
|
||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
orchestrator,
|
orchestrator,
|
||||||
projects,
|
projects,
|
||||||
policies,
|
policies,
|
||||||
|
mcp_tool_permissions,
|
||||||
ticket_tools,
|
ticket_tools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,7 +133,10 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
"identité requester interne absente pour l'outil".to_owned(),
|
"identité requester interne absente pour l'outil".to_owned(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
enforce_tool_policy(self.policies.as_ref(), &requester, name, &value)?;
|
let ephemeral_policy = self.policies.get_policy(&requester);
|
||||||
|
if let Some(policy) = &ephemeral_policy {
|
||||||
|
enforce_policy(policy, &requester, name, &value)?;
|
||||||
|
}
|
||||||
let project = self
|
let project = self
|
||||||
.projects
|
.projects
|
||||||
.list_projects()
|
.list_projects()
|
||||||
@ -142,6 +149,14 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
"projet introuvable pour root `{project_root}`"
|
"projet introuvable pour root `{project_root}`"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
enforce_durable_tool_policy(
|
||||||
|
self.mcp_tool_permissions.as_ref(),
|
||||||
|
&project,
|
||||||
|
ephemeral_policy.as_ref(),
|
||||||
|
&requester,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) {
|
if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) {
|
||||||
let value = self
|
let value = self
|
||||||
.ticket_tools
|
.ticket_tools
|
||||||
@ -178,6 +193,71 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enforce_durable_tool_policy(
|
||||||
|
store: &dyn McpToolPermissionStore,
|
||||||
|
project: &Project,
|
||||||
|
ephemeral_policy: Option<&AgentToolPolicy>,
|
||||||
|
requester: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), ToolInvocationError> {
|
||||||
|
let Some(policy) = durable_tool_policy(store, project, ephemeral_policy, requester).await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if policy.permits(name) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let requester = if requester.is_empty() {
|
||||||
|
"mcp"
|
||||||
|
} else {
|
||||||
|
requester
|
||||||
|
};
|
||||||
|
Err(ToolInvocationError::Rejected(format!(
|
||||||
|
"MCP tool `{name}` is not permitted for requester {requester}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn durable_tool_policy(
|
||||||
|
store: &dyn McpToolPermissionStore,
|
||||||
|
project: &Project,
|
||||||
|
ephemeral_policy: Option<&AgentToolPolicy>,
|
||||||
|
requester: &str,
|
||||||
|
) -> Result<Option<McpToolPolicy>, ToolInvocationError> {
|
||||||
|
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
|
||||||
|
let policy = if let Some(agent_id) = requester_agent_id(requester) {
|
||||||
|
let doc = store
|
||||||
|
.load_mcp_tool_permissions(project)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}"))
|
||||||
|
})?;
|
||||||
|
doc.effective_policy(
|
||||||
|
agent_id,
|
||||||
|
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
|
||||||
|
&known_tools,
|
||||||
|
)
|
||||||
|
.map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))?
|
||||||
|
} else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
|
||||||
|
McpToolPolicy::read_only(
|
||||||
|
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
|
||||||
|
&known_tools,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}"))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(policy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requester_agent_id(requester: &str) -> Option<AgentId> {
|
||||||
|
uuid::Uuid::parse_str(requester)
|
||||||
|
.ok()
|
||||||
|
.map(AgentId::from_uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn enforce_tool_policy(
|
fn enforce_tool_policy(
|
||||||
policies: &dyn AgentToolPolicyStore,
|
policies: &dyn AgentToolPolicyStore,
|
||||||
requester: &str,
|
requester: &str,
|
||||||
@ -239,8 +319,11 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use domain::ports::{AgentToolPolicyStore, ProjectStore};
|
use domain::ports::{AgentToolPolicyStore, McpToolPermissionStore, ProjectStore};
|
||||||
use domain::{Project, ProjectId, ProjectPath, RemoteRef, StoreError, Workspace};
|
use domain::{
|
||||||
|
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||||
|
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||||
|
};
|
||||||
use infrastructure::TicketToolError;
|
use infrastructure::TicketToolError;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -277,6 +360,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FakeMcpToolPermissions {
|
||||||
|
doc: Mutex<ProjectMcpToolPermissions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeMcpToolPermissions {
|
||||||
|
fn new(doc: ProjectMcpToolPermissions) -> Self {
|
||||||
|
Self {
|
||||||
|
doc: Mutex::new(doc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpToolPermissionStore for FakeMcpToolPermissions {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProjectStore for FakeProjects {
|
impl ProjectStore for FakeProjects {
|
||||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||||
@ -349,6 +463,30 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mcp_permissions(doc: ProjectMcpToolPermissions) -> Arc<dyn McpToolPermissionStore> {
|
||||||
|
Arc::new(FakeMcpToolPermissions::new(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions {
|
||||||
|
let known_tools = infrastructure::orchestrator::mcp::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()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_tool_policy_rejects_tool_outside_allowlist() {
|
fn openai_tool_policy_rejects_tool_outside_allowlist() {
|
||||||
let policies = FakePolicies::default();
|
let policies = FakePolicies::default();
|
||||||
@ -438,6 +576,7 @@ mod tests {
|
|||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
policies,
|
policies,
|
||||||
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -486,4 +625,112 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(temp);
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_general_agent_without_durable_override_is_read_only() {
|
||||||
|
let temp = std::env::temp_dir().join(format!(
|
||||||
|
"idea-openai-mcp-permissions-readonly-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let core = crate::BackendCore::build(temp.clone());
|
||||||
|
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
Arc::new(FakePolicies::default()),
|
||||||
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
|
ticket_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
invoker
|
||||||
|
.call(
|
||||||
|
"idea_ticket_list",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("read tool must pass the durable read-only policy");
|
||||||
|
|
||||||
|
for (tool, arguments) in [
|
||||||
|
(
|
||||||
|
"idea_memory_write",
|
||||||
|
json!({ "slug": "note-a", "content": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_ask_agent",
|
||||||
|
json!({ "target": "architect", "task": "do it" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_run_in_background",
|
||||||
|
json!({ "label": "task", "command": "echo" }),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut payload = arguments.as_object().unwrap().clone();
|
||||||
|
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
|
||||||
|
payload.insert(REQUESTER_ARG.to_owned(), json!(requester.clone()));
|
||||||
|
let err = invoker
|
||||||
|
.call(tool, &Value::Object(payload).to_string())
|
||||||
|
.await
|
||||||
|
.expect_err("write tool must be rejected without durable override");
|
||||||
|
assert!(
|
||||||
|
matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)),
|
||||||
|
"expected readable rejection for {tool}, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1, "only the read ticket tool should run");
|
||||||
|
assert_eq!(calls[0].1, "idea_ticket_list");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_durable_agent_override_allows_explicit_write_tool() {
|
||||||
|
let temp =
|
||||||
|
std::env::temp_dir().join(format!("idea-openai-mcp-permissions-{}", Uuid::new_v4()));
|
||||||
|
let core = crate::BackendCore::build(temp.clone());
|
||||||
|
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||||
|
let requester = agent.to_string();
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
Arc::new(FakePolicies::default()),
|
||||||
|
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
||||||
|
ticket_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = invoker
|
||||||
|
.call(
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"ref": "#7",
|
||||||
|
"expectedVersion": 1,
|
||||||
|
"carnet": "body",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("durable override should let the write tool reach the provider");
|
||||||
|
let result: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(result["requester"], requester);
|
||||||
|
assert_eq!(result["ref"], "#7");
|
||||||
|
|
||||||
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].1, "idea_ticket_update_carnet");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ pub mod layout;
|
|||||||
pub mod live_state;
|
pub mod live_state;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
pub mod mcp_tool_permissions;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_harvest;
|
pub mod memory_harvest;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
@ -84,6 +85,11 @@ pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|||||||
|
|
||||||
pub use agent_tool_policy::AgentToolPolicy;
|
pub use agent_tool_policy::AgentToolPolicy;
|
||||||
|
|
||||||
|
pub use mcp_tool_permissions::{
|
||||||
|
AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, ProjectMcpToolPermissions,
|
||||||
|
MCP_TOOL_PERMISSIONS_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
pub use background_task::{
|
pub use background_task::{
|
||||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
||||||
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||||
@ -204,11 +210,11 @@ pub use ports::{
|
|||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||||
Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError,
|
||||||
ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||||
RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
||||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore,
|
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||||
WindowStateStore,
|
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
314
crates/domain/src/mcp_tool_permissions.rs
Normal file
314
crates/domain/src/mcp_tool_permissions.rs
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
//! Durable MCP tool permissions for IdeA agents.
|
||||||
|
//!
|
||||||
|
//! This model is deliberately separate from [`crate::permission`]: file/process
|
||||||
|
//! permissions and Landlock sandboxing are OS-level capabilities, while MCP tools
|
||||||
|
//! are application capabilities enforced before dispatch.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::ids::AgentId;
|
||||||
|
|
||||||
|
/// Current schema version for `.ideai/mcp-tool-permissions.json`.
|
||||||
|
pub const MCP_TOOL_PERMISSIONS_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Validation errors for durable MCP tool permission documents.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum McpToolPermissionError {
|
||||||
|
/// The stored document uses an unsupported schema version.
|
||||||
|
#[error("unsupported MCP tool permissions version: {0}")]
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
/// A tool name is empty.
|
||||||
|
#[error("MCP tool name cannot be empty")]
|
||||||
|
EmptyToolName,
|
||||||
|
/// A tool name is not in the known MCP catalogue.
|
||||||
|
#[error("unknown MCP tool: {0}")]
|
||||||
|
UnknownTool(String),
|
||||||
|
/// A tool appears more than once in one allowlist.
|
||||||
|
#[error("duplicate MCP tool: {0}")]
|
||||||
|
DuplicateTool(String),
|
||||||
|
/// An agent has more than one override entry.
|
||||||
|
#[error("duplicate MCP tool policy override for agent {0}")]
|
||||||
|
DuplicateAgent(AgentId),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allowlist of MCP tools.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpToolPolicy {
|
||||||
|
/// Exact MCP tool names allowed by this policy.
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpToolPolicy {
|
||||||
|
/// Builds and validates a policy against the known catalogue.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`McpToolPermissionError`] when a name is empty, duplicate, or unknown.
|
||||||
|
pub fn new(
|
||||||
|
allowed_tools: Vec<String>,
|
||||||
|
known_tools: &[&str],
|
||||||
|
) -> Result<Self, McpToolPermissionError> {
|
||||||
|
let policy = Self { allowed_tools };
|
||||||
|
policy.validate(known_tools)?;
|
||||||
|
Ok(policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the canonical read-only fallback policy.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`McpToolPermissionError`] if the provided read-only set is not a valid
|
||||||
|
/// subset of the known catalogue.
|
||||||
|
pub fn read_only(
|
||||||
|
read_only_tools: &[&str],
|
||||||
|
known_tools: &[&str],
|
||||||
|
) -> Result<Self, McpToolPermissionError> {
|
||||||
|
Self::new(
|
||||||
|
read_only_tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| (*tool).to_owned())
|
||||||
|
.collect(),
|
||||||
|
known_tools,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether `tool` is allowed by this allowlist.
|
||||||
|
#[must_use]
|
||||||
|
pub fn permits(&self, tool: &str) -> bool {
|
||||||
|
self.allowed_tools.iter().any(|allowed| allowed == tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the policy against a known catalogue.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`McpToolPermissionError`] when a name is empty, duplicate, or unknown.
|
||||||
|
pub fn validate(&self, known_tools: &[&str]) -> Result<(), McpToolPermissionError> {
|
||||||
|
let known = known_tools.iter().copied().collect::<HashSet<_>>();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for tool in &self.allowed_tools {
|
||||||
|
if tool.is_empty() {
|
||||||
|
return Err(McpToolPermissionError::EmptyToolName);
|
||||||
|
}
|
||||||
|
if !known.contains(tool.as_str()) {
|
||||||
|
return Err(McpToolPermissionError::UnknownTool(tool.clone()));
|
||||||
|
}
|
||||||
|
if !seen.insert(tool.as_str()) {
|
||||||
|
return Err(McpToolPermissionError::DuplicateTool(tool.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent-specific policy override.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentMcpToolPolicyOverride {
|
||||||
|
/// Agent whose effective policy is overridden.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// Replacement policy for the agent.
|
||||||
|
pub policy: McpToolPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentMcpToolPolicyOverride {
|
||||||
|
/// Builds an agent override.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(agent_id: AgentId, policy: McpToolPolicy) -> Self {
|
||||||
|
Self { agent_id, policy }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project document persisted as `.ideai/mcp-tool-permissions.json`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProjectMcpToolPermissions {
|
||||||
|
/// Schema version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Optional project-wide default policy.
|
||||||
|
pub project_default: Option<McpToolPolicy>,
|
||||||
|
/// Sparse agent overrides.
|
||||||
|
pub agents: Vec<AgentMcpToolPolicyOverride>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProjectMcpToolPermissions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: MCP_TOOL_PERMISSIONS_VERSION,
|
||||||
|
project_default: None,
|
||||||
|
agents: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectMcpToolPermissions {
|
||||||
|
/// Builds and validates a permissions document.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`McpToolPermissionError`] when the document contains invalid policies or
|
||||||
|
/// duplicate agent overrides.
|
||||||
|
pub fn new(
|
||||||
|
project_default: Option<McpToolPolicy>,
|
||||||
|
agents: Vec<AgentMcpToolPolicyOverride>,
|
||||||
|
known_tools: &[&str],
|
||||||
|
) -> Result<Self, McpToolPermissionError> {
|
||||||
|
let doc = Self {
|
||||||
|
version: MCP_TOOL_PERMISSIONS_VERSION,
|
||||||
|
project_default,
|
||||||
|
agents,
|
||||||
|
};
|
||||||
|
doc.validate(known_tools)?;
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates version, policy contents, and override uniqueness.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`McpToolPermissionError`] when an invariant is violated.
|
||||||
|
pub fn validate(&self, known_tools: &[&str]) -> Result<(), McpToolPermissionError> {
|
||||||
|
if self.version != MCP_TOOL_PERMISSIONS_VERSION {
|
||||||
|
return Err(McpToolPermissionError::UnsupportedVersion(self.version));
|
||||||
|
}
|
||||||
|
if let Some(policy) = &self.project_default {
|
||||||
|
policy.validate(known_tools)?;
|
||||||
|
}
|
||||||
|
let mut seen_agents = HashSet::new();
|
||||||
|
for override_ in &self.agents {
|
||||||
|
if !seen_agents.insert(override_.agent_id) {
|
||||||
|
return Err(McpToolPermissionError::DuplicateAgent(override_.agent_id));
|
||||||
|
}
|
||||||
|
override_.policy.validate(known_tools)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the effective policy for `agent_id`.
|
||||||
|
///
|
||||||
|
/// Resolution order: agent override > project default > canonical read-only
|
||||||
|
/// fallback.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`McpToolPermissionError`] if the document or fallback set is invalid.
|
||||||
|
pub fn effective_policy(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
read_only_tools: &[&str],
|
||||||
|
known_tools: &[&str],
|
||||||
|
) -> Result<McpToolPolicy, McpToolPermissionError> {
|
||||||
|
self.validate(known_tools)?;
|
||||||
|
if let Some(override_) = self
|
||||||
|
.agents
|
||||||
|
.iter()
|
||||||
|
.find(|override_| override_.agent_id == agent_id)
|
||||||
|
{
|
||||||
|
return Ok(override_.policy.clone());
|
||||||
|
}
|
||||||
|
if let Some(policy) = &self.project_default {
|
||||||
|
return Ok(policy.clone());
|
||||||
|
}
|
||||||
|
McpToolPolicy::read_only(read_only_tools, known_tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const KNOWN: &[&str] = &["idea_memory_read", "idea_memory_write", "idea_ticket_list"];
|
||||||
|
const READ_ONLY: &[&str] = &["idea_memory_read", "idea_ticket_list"];
|
||||||
|
|
||||||
|
fn agent(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_policy_falls_back_to_read_only_when_unconfigured() {
|
||||||
|
let doc = ProjectMcpToolPermissions::default();
|
||||||
|
|
||||||
|
let effective = doc.effective_policy(agent(1), READ_ONLY, KNOWN).unwrap();
|
||||||
|
|
||||||
|
assert!(effective.permits("idea_memory_read"));
|
||||||
|
assert!(effective.permits("idea_ticket_list"));
|
||||||
|
assert!(!effective.permits("idea_memory_write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_override_replaces_project_default() {
|
||||||
|
let project_default =
|
||||||
|
McpToolPolicy::new(vec!["idea_memory_read".to_owned()], KNOWN).unwrap();
|
||||||
|
let override_policy =
|
||||||
|
McpToolPolicy::new(vec!["idea_memory_write".to_owned()], KNOWN).unwrap();
|
||||||
|
let doc = ProjectMcpToolPermissions::new(
|
||||||
|
Some(project_default),
|
||||||
|
vec![AgentMcpToolPolicyOverride::new(agent(7), override_policy)],
|
||||||
|
KNOWN,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let effective = doc.effective_policy(agent(7), READ_ONLY, KNOWN).unwrap();
|
||||||
|
|
||||||
|
assert!(effective.permits("idea_memory_write"));
|
||||||
|
assert!(!effective.permits("idea_memory_read"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_default_applies_when_no_agent_override_exists() {
|
||||||
|
let project_default =
|
||||||
|
McpToolPolicy::new(vec!["idea_memory_write".to_owned()], KNOWN).unwrap();
|
||||||
|
let doc = ProjectMcpToolPermissions::new(Some(project_default), Vec::new(), KNOWN).unwrap();
|
||||||
|
|
||||||
|
let effective = doc.effective_policy(agent(8), READ_ONLY, KNOWN).unwrap();
|
||||||
|
|
||||||
|
assert!(effective.permits("idea_memory_write"));
|
||||||
|
assert!(!effective.permits("idea_memory_read"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_tool_is_rejected_in_allowlist() {
|
||||||
|
let err = McpToolPolicy::new(vec!["idea_unknown".to_owned()], KNOWN).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
McpToolPermissionError::UnknownTool("idea_unknown".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_tool_is_rejected_in_allowlist() {
|
||||||
|
let err = McpToolPolicy::new(
|
||||||
|
vec!["idea_memory_read".to_owned(), "idea_memory_read".to_owned()],
|
||||||
|
KNOWN,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
McpToolPermissionError::DuplicateTool("idea_memory_read".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_tool_name_is_rejected_in_allowlist() {
|
||||||
|
let err = McpToolPolicy::new(vec![String::new()], KNOWN).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err, McpToolPermissionError::EmptyToolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_agent_override_is_rejected() {
|
||||||
|
let err = ProjectMcpToolPermissions::new(
|
||||||
|
None,
|
||||||
|
vec![
|
||||||
|
AgentMcpToolPolicyOverride::new(agent(3), McpToolPolicy::default()),
|
||||||
|
AgentMcpToolPolicyOverride::new(agent(3), McpToolPolicy::default()),
|
||||||
|
],
|
||||||
|
KNOWN,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err, McpToolPermissionError::DuplicateAgent(agent(3)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,7 @@ use crate::issue::{
|
|||||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||||
};
|
};
|
||||||
use crate::markdown::MarkdownDoc;
|
use crate::markdown::MarkdownDoc;
|
||||||
|
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
||||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||||
use crate::model_server::{
|
use crate::model_server::{
|
||||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||||
@ -1791,6 +1792,33 @@ pub trait PermissionStore: Send + Sync {
|
|||||||
) -> Result<(), StoreError>;
|
) -> Result<(), StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads/writes a project's `.ideai/mcp-tool-permissions.json`.
|
||||||
|
///
|
||||||
|
/// This is intentionally distinct from [`PermissionStore`]: it governs IdeA MCP
|
||||||
|
/// application tools, not filesystem/process/sandbox capabilities.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpToolPermissionStore: Send + Sync {
|
||||||
|
/// Loads the project's MCP tool permission document. Missing file returns the
|
||||||
|
/// default empty document.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on I/O, deserialisation, or validation failure.
|
||||||
|
async fn load_mcp_tool_permissions(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
) -> Result<ProjectMcpToolPermissions, StoreError>;
|
||||||
|
|
||||||
|
/// Saves the project's MCP tool permission document.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on I/O, serialisation, or validation failure.
|
||||||
|
async fn save_mcp_tool_permissions(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
permissions: &ProjectMcpToolPermissions,
|
||||||
|
) -> Result<(), StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistence port for first-class background tasks.
|
/// Persistence port for first-class background tasks.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait BackgroundTaskStore: Send + Sync {
|
pub trait BackgroundTaskStore: Send + Sync {
|
||||||
|
|||||||
@ -99,8 +99,8 @@ pub use store::{
|
|||||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||||
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore,
|
||||||
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -34,6 +34,70 @@ pub struct ToolDef {
|
|||||||
pub input_schema: Value,
|
pub input_schema: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Coarse MCP tool access class used to derive default permissions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum McpToolAccess {
|
||||||
|
/// Read-only application capability, allowed by default.
|
||||||
|
Read,
|
||||||
|
/// Write/action/execution capability, denied by default.
|
||||||
|
WriteAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical read-only MCP tools allowed by default.
|
||||||
|
pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||||
|
"idea_list_agents",
|
||||||
|
"idea_context_read",
|
||||||
|
"idea_memory_read",
|
||||||
|
"idea_skill_read",
|
||||||
|
"idea_workstate_read",
|
||||||
|
"idea_ticket_read",
|
||||||
|
"idea_ticket_list",
|
||||||
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_sprint_list",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Canonical write/action MCP tools denied by default.
|
||||||
|
pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||||
|
"idea_ask_agent",
|
||||||
|
"idea_run_in_background",
|
||||||
|
"idea_launch_agent",
|
||||||
|
"idea_stop_agent",
|
||||||
|
"idea_update_context",
|
||||||
|
"idea_context_propose",
|
||||||
|
"idea_memory_write",
|
||||||
|
"idea_workstate_set",
|
||||||
|
"idea_create_skill",
|
||||||
|
"idea_ticket_create",
|
||||||
|
"idea_ticket_update",
|
||||||
|
"idea_ticket_update_status",
|
||||||
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
"idea_ticket_link",
|
||||||
|
"idea_ticket_unlink",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All MCP tool names that have an explicit access classification.
|
||||||
|
#[must_use]
|
||||||
|
pub fn classified_tool_names() -> Vec<&'static str> {
|
||||||
|
READ_ONLY_TOOLS
|
||||||
|
.iter()
|
||||||
|
.chain(WRITE_ACTION_TOOLS.iter())
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the explicit access class for `tool`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tool_access(tool: &str) -> Option<McpToolAccess> {
|
||||||
|
if READ_ONLY_TOOLS.contains(&tool) {
|
||||||
|
Some(McpToolAccess::Read)
|
||||||
|
} else if WRITE_ACTION_TOOLS.contains(&tool) {
|
||||||
|
Some(McpToolAccess::WriteAction)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors mapping a tool call into a validated [`OrchestratorCommand`].
|
/// Errors mapping a tool call into a validated [`OrchestratorCommand`].
|
||||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
pub enum ToolMapError {
|
pub enum ToolMapError {
|
||||||
@ -498,6 +562,7 @@ fn optional_u64(value: Option<&Value>, tool: &str) -> Result<Option<u64>, ToolMa
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use domain::OrchestratorVisibility;
|
use domain::OrchestratorVisibility;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
|
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
|
||||||
const REQ: &str = "11111111-1111-1111-1111-111111111111";
|
const REQ: &str = "11111111-1111-1111-1111-111111111111";
|
||||||
@ -507,6 +572,44 @@ mod tests {
|
|||||||
map_tool_call(name, args, "")
|
map_tool_call(name, args, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalogue_tools_have_explicit_read_or_write_access() {
|
||||||
|
let catalogue_names = catalogue()
|
||||||
|
.into_iter()
|
||||||
|
.map(|tool| tool.name)
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
for tool in &catalogue_names {
|
||||||
|
assert!(
|
||||||
|
tool_access(tool).is_some(),
|
||||||
|
"MCP catalogue tool `{tool}` must choose Read or WriteAction explicitly"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for tool in classified_tool_names() {
|
||||||
|
assert!(
|
||||||
|
catalogue_names.contains(tool),
|
||||||
|
"classified MCP tool `{tool}` is not advertised by the catalogue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_and_write_tool_classifications_are_disjoint_and_unique() {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for tool in READ_ONLY_TOOLS {
|
||||||
|
assert!(seen.insert(*tool), "duplicate read-only MCP tool `{tool}`");
|
||||||
|
assert_eq!(tool_access(tool), Some(McpToolAccess::Read));
|
||||||
|
}
|
||||||
|
for tool in WRITE_ACTION_TOOLS {
|
||||||
|
assert!(
|
||||||
|
seen.insert(*tool),
|
||||||
|
"MCP tool `{tool}` is classified more than once"
|
||||||
|
);
|
||||||
|
assert_eq!(tool_access(tool), Some(McpToolAccess::WriteAction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
|
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
|
||||||
let requester = uuid::Uuid::from_u128(42).to_string();
|
let requester = uuid::Uuid::from_u128(42).to_string();
|
||||||
|
|||||||
81
crates/infrastructure/src/store/mcp_tool_permission.rs
Normal file
81
crates/infrastructure/src/store/mcp_tool_permission.rs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//! Filesystem-backed [`McpToolPermissionStore`] for project MCP tool permissions.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use domain::ports::{FileSystem, FsError, McpToolPermissionStore, RemotePath, StoreError};
|
||||||
|
use domain::{Project, ProjectMcpToolPermissions};
|
||||||
|
|
||||||
|
use crate::orchestrator::mcp::tools;
|
||||||
|
|
||||||
|
const MCP_TOOL_PERMISSIONS_FILE: &str = "mcp-tool-permissions.json";
|
||||||
|
|
||||||
|
/// JSON-file implementation for `<project>/.ideai/mcp-tool-permissions.json`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsMcpToolPermissionStore {
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsMcpToolPermissionStore {
|
||||||
|
/// Builds the store from an injected filesystem port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||||
|
Self { fs }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(project: &Project) -> RemotePath {
|
||||||
|
let root = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||||
|
RemotePath::new(format!("{root}/.ideai/{MCP_TOOL_PERMISSIONS_FILE}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_ideai(&self, project: &Project) -> Result<(), StoreError> {
|
||||||
|
let root = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&RemotePath::new(format!("{root}/.ideai")))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(permissions: &ProjectMcpToolPermissions) -> Result<(), StoreError> {
|
||||||
|
let known_tools = tools::classified_tool_names();
|
||||||
|
permissions
|
||||||
|
.validate(&known_tools)
|
||||||
|
.map_err(|e| StoreError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpToolPermissionStore for FsMcpToolPermissionStore {
|
||||||
|
async fn load_mcp_tool_permissions(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
) -> Result<ProjectMcpToolPermissions, StoreError> {
|
||||||
|
match self.fs.read(&Self::path(project)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let permissions = serde_json::from_slice::<ProjectMcpToolPermissions>(&bytes)
|
||||||
|
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
Self::validate(&permissions)?;
|
||||||
|
Ok(permissions)
|
||||||
|
}
|
||||||
|
Err(FsError::NotFound(_)) => Ok(ProjectMcpToolPermissions::default()),
|
||||||
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_mcp_tool_permissions(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
permissions: &ProjectMcpToolPermissions,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Self::validate(permissions)?;
|
||||||
|
self.ensure_ideai(project).await?;
|
||||||
|
let mut bytes = serde_json::to_vec_pretty(permissions)
|
||||||
|
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
self.fs
|
||||||
|
.write(&Self::path(project), &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ mod context;
|
|||||||
mod device_session;
|
mod device_session;
|
||||||
mod embedder;
|
mod embedder;
|
||||||
mod live_state;
|
mod live_state;
|
||||||
|
mod mcp_tool_permission;
|
||||||
mod memory;
|
mod memory;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod profile;
|
mod profile;
|
||||||
@ -31,6 +32,7 @@ pub use embedder::{
|
|||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
pub use live_state::FsLiveStateStore;
|
pub use live_state::FsLiveStateStore;
|
||||||
|
pub use mcp_tool_permission::FsMcpToolPermissionStore;
|
||||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||||
pub use permission::FsPermissionStore;
|
pub use permission::FsPermissionStore;
|
||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
176
crates/infrastructure/tests/mcp_tool_permission_store.rs
Normal file
176
crates/infrastructure/tests/mcp_tool_permission_store.rs
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
//! L2 integration tests for [`FsMcpToolPermissionStore`] against a real temp project.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ids::{AgentId, ProjectId};
|
||||||
|
use domain::ports::{FileSystem, McpToolPermissionStore, StoreError};
|
||||||
|
use domain::project::{Project, ProjectPath};
|
||||||
|
use domain::remote::RemoteRef;
|
||||||
|
use domain::{
|
||||||
|
AgentMcpToolPolicyOverride, McpToolPolicy, ProjectMcpToolPermissions,
|
||||||
|
MCP_TOOL_PERMISSIONS_VERSION,
|
||||||
|
};
|
||||||
|
use infrastructure::orchestrator::mcp::tools::{classified_tool_names, READ_ONLY_TOOLS};
|
||||||
|
use infrastructure::{FsMcpToolPermissionStore, LocalFileSystem};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A unique scratch project directory under the OS temp dir, cleaned up on drop.
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let p =
|
||||||
|
std::env::temp_dir().join(format!("idea-l2-mcp-tool-permissions-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
Self(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_root(&self) -> String {
|
||||||
|
self.0.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store() -> FsMcpToolPermissionStore {
|
||||||
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||||
|
FsMcpToolPermissionStore::new(fs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project(tmp: &TempDir) -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::new_random(),
|
||||||
|
"mcp-tool-permissions",
|
||||||
|
ProjectPath::new(tmp.project_root()).unwrap(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
1_700_000_000_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy(tools: &[&str]) -> McpToolPolicy {
|
||||||
|
McpToolPolicy::new(
|
||||||
|
tools.iter().map(|tool| (*tool).to_owned()).collect(),
|
||||||
|
&classified_tool_names(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_mcp_tool_permissions_file_returns_default_document() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let project = project(&tmp);
|
||||||
|
|
||||||
|
let loaded = store().load_mcp_tool_permissions(&project).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded, ProjectMcpToolPermissions::default());
|
||||||
|
assert_eq!(loaded.version, MCP_TOOL_PERMISSIONS_VERSION);
|
||||||
|
|
||||||
|
let effective = loaded
|
||||||
|
.effective_policy(
|
||||||
|
AgentId::new_random(),
|
||||||
|
READ_ONLY_TOOLS,
|
||||||
|
&classified_tool_names(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(effective.permits("idea_context_read"));
|
||||||
|
assert!(!effective.permits("idea_memory_write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_then_load_roundtrips_project_default_and_agent_override() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let project = project(&tmp);
|
||||||
|
let agent = AgentId::new_random();
|
||||||
|
let doc = ProjectMcpToolPermissions::new(
|
||||||
|
Some(policy(&["idea_context_read", "idea_memory_read"])),
|
||||||
|
vec![AgentMcpToolPolicyOverride::new(
|
||||||
|
agent,
|
||||||
|
policy(&["idea_ticket_update"]),
|
||||||
|
)],
|
||||||
|
&classified_tool_names(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = store();
|
||||||
|
store
|
||||||
|
.save_mcp_tool_permissions(&project, &doc)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = store.load_mcp_tool_permissions(&project).await.unwrap();
|
||||||
|
assert_eq!(loaded, doc);
|
||||||
|
|
||||||
|
let effective = loaded
|
||||||
|
.effective_policy(agent, READ_ONLY_TOOLS, &classified_tool_names())
|
||||||
|
.unwrap();
|
||||||
|
assert!(effective.permits("idea_ticket_update"));
|
||||||
|
assert!(!effective.permits("idea_context_read"));
|
||||||
|
|
||||||
|
let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json");
|
||||||
|
assert!(
|
||||||
|
path.exists(),
|
||||||
|
"store writes under .ideai/mcp-tool-permissions.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_rejects_unknown_tool_permissions() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let project = project(&tmp);
|
||||||
|
let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
path,
|
||||||
|
r#"{
|
||||||
|
"version": 1,
|
||||||
|
"projectDefault": { "allowedTools": ["idea_unknown"] },
|
||||||
|
"agents": []
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = store()
|
||||||
|
.load_mcp_tool_permissions(&project)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, StoreError::Serialization(ref message) if message.contains("unknown MCP tool: idea_unknown")),
|
||||||
|
"unknown tools must not become latent permissions, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_rejects_invalid_allowlist_before_writing() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let project = project(&tmp);
|
||||||
|
let invalid = ProjectMcpToolPermissions {
|
||||||
|
version: MCP_TOOL_PERMISSIONS_VERSION,
|
||||||
|
project_default: Some(McpToolPolicy {
|
||||||
|
allowed_tools: vec!["idea_unknown".to_owned()],
|
||||||
|
}),
|
||||||
|
agents: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = store()
|
||||||
|
.save_mcp_tool_permissions(&project, &invalid)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, StoreError::Serialization(ref message) if message.contains("unknown MCP tool: idea_unknown")),
|
||||||
|
"invalid allowlists must be rejected before persistence, got {err:?}"
|
||||||
|
);
|
||||||
|
assert!(!tmp
|
||||||
|
.0
|
||||||
|
.join(".ideai")
|
||||||
|
.join("mcp-tool-permissions.json")
|
||||||
|
.exists());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user