//! MCP tool policy for constrained assistant sessions. //! //! This is a pure domain value. It models what a requester may do through the //! MCP surface; it does not know how the MCP server enforces it. use serde::{Deserialize, Serialize}; use crate::issue::IssueRef; /// Tool policy attached to one agent/requester. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentToolPolicy { /// Explicitly allowed MCP tool names. pub allow: Vec, /// Optional issue boundary for ticket mutations. pub bound_issue: Option, /// Whether tools outside [`Self::allow`] are denied. pub deny_others: bool, } impl AgentToolPolicy { /// Builds a tool policy. #[must_use] pub fn new(allow: Vec, bound_issue: Option, deny_others: bool) -> Self { Self { allow, bound_issue, deny_others, } } /// Returns true when `tool` is allowed by the allowlist/default rule. #[must_use] pub fn permits(&self, tool: &str) -> bool { self.allow.iter().any(|allowed| allowed == tool) || !self.deny_others } /// Returns true when `tool` may mutate the supplied ticket reference. /// /// The method first applies the tool allowlist. Then, for known ticket mutation /// tools, an optional [`Self::bound_issue`] fences the mutation to that single /// issue reference. #[must_use] pub fn permits_ticket_mutation(&self, tool: &str, issue_ref: IssueRef) -> bool { if !self.permits(tool) { return false; } if !is_ticket_mutation_tool(tool) { return true; } self.bound_issue.map_or(true, |bound| bound == issue_ref) } } fn is_ticket_mutation_tool(tool: &str) -> bool { matches!( tool, "idea_ticket_update" | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" | "idea_ticket_link" | "idea_ticket_unlink" | "idea_ticket_create" ) } #[cfg(test)] mod tests { use std::str::FromStr; use super::*; fn issue_ref(raw: &str) -> IssueRef { IssueRef::from_str(raw).unwrap() } #[test] fn permits_allowlisted_tool_when_deny_others_is_true() { let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, true); assert!(policy.permits("idea_ticket_read")); assert!(!policy.permits("idea_ask_agent")); } #[test] fn deny_others_false_allows_non_listed_tools() { let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, false); assert!(policy.permits("idea_ticket_read")); assert!(policy.permits("idea_ask_agent")); } #[test] fn ticket_mutation_is_bound_to_the_configured_issue() { let policy = AgentToolPolicy::new( vec!["idea_ticket_update".to_owned()], Some(issue_ref("#7")), true, ); assert!(policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7"))); assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8"))); } #[test] fn ticket_mutation_requires_the_tool_itself_to_be_allowed() { let policy = AgentToolPolicy::new( vec!["idea_ticket_read".to_owned()], Some(issue_ref("#7")), true, ); assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7"))); } }