use std::collections::HashSet;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalClassification {
RequiresApproval,
NoApproval,
UserConfigurable,
}
pub fn classify(tool_id: &str) -> ApprovalClassification {
let id = tool_id.trim().to_ascii_lowercase();
if id.is_empty() {
return ApprovalClassification::UserConfigurable;
}
if never_gates_table().contains(id.as_str()) {
return ApprovalClassification::NoApproval;
}
if always_gates_table().contains(id.as_str()) {
return ApprovalClassification::RequiresApproval;
}
for prefix in ALWAYS_GATE_PREFIXES {
if id.starts_with(prefix) {
return ApprovalClassification::RequiresApproval;
}
}
for prefix in NEVER_GATE_PREFIXES {
if id.starts_with(prefix) {
return ApprovalClassification::NoApproval;
}
}
for suffix in ALWAYS_GATE_SUFFIXES {
if id.ends_with(suffix) {
return ApprovalClassification::RequiresApproval;
}
}
ApprovalClassification::UserConfigurable
}
pub fn classify_node_allowlist<I>(allowlist: I) -> ApprovalClassification
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let mut has_unknown = false;
let mut saw_any = false;
for tool in allowlist {
saw_any = true;
match classify(tool.as_ref()) {
ApprovalClassification::RequiresApproval => {
return ApprovalClassification::RequiresApproval
}
ApprovalClassification::UserConfigurable => has_unknown = true,
ApprovalClassification::NoApproval => {}
}
}
if !saw_any {
return ApprovalClassification::UserConfigurable;
}
if has_unknown {
ApprovalClassification::UserConfigurable
} else {
ApprovalClassification::NoApproval
}
}
pub fn allowlist_is_wildcard<I>(allowlist: I) -> bool
where
I: IntoIterator,
I::Item: AsRef<str>,
{
allowlist
.into_iter()
.any(|item| matches!(item.as_ref().trim(), "*" | "**" | "mcp.*"))
}
const NEVER_GATES_LIST: &[&str] = &[
"read",
"glob",
"grep",
"list_directory",
"list_files",
"websearch",
"web_search",
"fetch_url",
"memory_search",
"memory_get",
"list_sessions",
"describe_workflow",
"describe_run",
"kb_search",
"kb_get_document",
"workflow_plan_preview",
"workflow_plan_validate",
];
const ALWAYS_GATES_LIST: &[&str] = &[
"rm",
"delete",
"unlink",
"rmdir",
"write",
"edit",
"patch",
"bash",
"shell",
"exec",
"git_push",
"git_force_push",
"send_email",
"send_mail",
"publish",
"post",
];
fn never_gates_table() -> &'static HashSet<&'static str> {
static TABLE: OnceLock<HashSet<&'static str>> = OnceLock::new();
TABLE.get_or_init(|| NEVER_GATES_LIST.iter().copied().collect())
}
fn always_gates_table() -> &'static HashSet<&'static str> {
static TABLE: OnceLock<HashSet<&'static str>> = OnceLock::new();
TABLE.get_or_init(|| ALWAYS_GATES_LIST.iter().copied().collect())
}
const ALWAYS_GATE_PREFIXES: &[&str] = &[
"mcp.hubspot.",
"mcp.salesforce.",
"mcp.pipedrive.",
"mcp.stripe.",
"mcp.paypal.",
"mcp.gmail.send",
"mcp.outlook.send",
"mcp.sendgrid.",
"mcp.mailgun.",
"mcp.linkedin.",
"mcp.twitter.",
"mcp.x.",
"mcp.threads.",
"mcp.bluesky.",
"mcp.googlecalendar.",
"mcp.calendly.",
"mcp.linear.",
"mcp.jira.",
"mcp.shortcut.",
"mcp.github.create_pull_request",
"mcp.github.merge_pull_request",
"mcp.github.create_issue",
"mcp.github.close_issue",
"mcp.github.update_issue",
"mcp.notion.create",
"mcp.notion.update",
"mcp.notion.delete",
"mcp.confluence.create",
"mcp.confluence.update",
"mcp.slack.post",
"mcp.slack.send",
"mcp.telegram.send",
"mcp.discord.send",
"coder.merge",
"coder.publish",
];
const NEVER_GATE_PREFIXES: &[&str] = &[
"mcp.github.list_",
"mcp.github.get_",
"mcp.github.search_",
"mcp.notion.search",
"mcp.notion.get",
"mcp.confluence.search",
"mcp.confluence.get",
"mcp.googlecalendar.list",
"mcp.googlecalendar.get",
"mcp.linear.list",
"mcp.linear.get",
"mcp.jira.search",
"mcp.jira.get",
"mcp.kb.",
"mcp.knowledge.",
"mcp.brave.",
"mcp.exa.",
"mcp.searxng.",
"mcp.serper.",
];
const ALWAYS_GATE_SUFFIXES: &[&str] = &[
".send",
".send_message",
".send_email",
".publish",
".post",
".create",
".update",
".delete",
".remove",
".merge",
".pay",
".charge",
".refund",
".transfer",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_only_built_ins_never_gate() {
for tool in ["read", "glob", "grep", "websearch", "kb_search"] {
assert_eq!(classify(tool), ApprovalClassification::NoApproval, "{tool}");
}
}
#[test]
fn destructive_built_ins_always_gate() {
for tool in ["rm", "delete", "write", "edit", "bash", "send_email"] {
assert_eq!(
classify(tool),
ApprovalClassification::RequiresApproval,
"{tool}"
);
}
}
#[test]
fn case_insensitive_matching() {
assert_eq!(classify("READ"), ApprovalClassification::NoApproval);
assert_eq!(
classify("Send_Email"),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn empty_id_is_user_configurable() {
assert_eq!(classify(""), ApprovalClassification::UserConfigurable);
assert_eq!(classify(" "), ApprovalClassification::UserConfigurable);
}
#[test]
fn crm_mcp_prefix_always_gates() {
for tool in [
"mcp.hubspot.create_contact",
"mcp.salesforce.update_account",
"mcp.pipedrive.add_deal",
] {
assert_eq!(
classify(tool),
ApprovalClassification::RequiresApproval,
"{tool}"
);
}
}
#[test]
fn payment_mcp_prefix_always_gates() {
assert_eq!(
classify("mcp.stripe.create_charge"),
ApprovalClassification::RequiresApproval
);
assert_eq!(
classify("mcp.paypal.send_payment"),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn github_read_verbs_never_gate_but_writes_do() {
assert_eq!(
classify("mcp.github.list_issues"),
ApprovalClassification::NoApproval
);
assert_eq!(
classify("mcp.github.get_pull_request"),
ApprovalClassification::NoApproval
);
assert_eq!(
classify("mcp.github.create_pull_request"),
ApprovalClassification::RequiresApproval
);
assert_eq!(
classify("mcp.github.merge_pull_request"),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn notion_read_vs_write() {
assert_eq!(
classify("mcp.notion.search"),
ApprovalClassification::NoApproval
);
assert_eq!(
classify("mcp.notion.get_page"),
ApprovalClassification::NoApproval
);
assert_eq!(
classify("mcp.notion.update_page"),
ApprovalClassification::RequiresApproval
);
assert_eq!(
classify("mcp.notion.delete_block"),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn suffix_heuristics_cover_unknown_servers() {
assert_eq!(
classify("mcp.unknown_vendor.send_message"),
ApprovalClassification::RequiresApproval,
);
assert_eq!(
classify("mcp.custom_app.publish"),
ApprovalClassification::RequiresApproval,
);
assert_eq!(
classify("mcp.workflow.delete"),
ApprovalClassification::RequiresApproval,
);
}
#[test]
fn unknown_tool_is_user_configurable() {
assert_eq!(
classify("mcp.brand_new.do_something"),
ApprovalClassification::UserConfigurable
);
assert_eq!(
classify("custom_internal_tool"),
ApprovalClassification::UserConfigurable
);
}
#[test]
fn search_provider_mcps_never_gate() {
for tool in [
"mcp.brave.search",
"mcp.exa.search",
"mcp.searxng.query",
"mcp.serper.web",
] {
assert_eq!(classify(tool), ApprovalClassification::NoApproval, "{tool}");
}
}
#[test]
fn classify_node_allowlist_returns_required_when_any_tool_requires() {
let allowlist = vec![
"read".to_string(),
"websearch".to_string(),
"mcp.hubspot.create_contact".to_string(),
];
assert_eq!(
classify_node_allowlist(&allowlist),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn classify_node_allowlist_returns_no_approval_for_pure_reads() {
let allowlist = vec![
"read".to_string(),
"glob".to_string(),
"websearch".to_string(),
"mcp.github.list_issues".to_string(),
];
assert_eq!(
classify_node_allowlist(&allowlist),
ApprovalClassification::NoApproval
);
}
#[test]
fn classify_node_allowlist_returns_user_configurable_when_unknown_present() {
let allowlist = vec!["read".to_string(), "mcp.unknown_thing.do_X".to_string()];
assert_eq!(
classify_node_allowlist(&allowlist),
ApprovalClassification::UserConfigurable
);
}
#[test]
fn classify_node_allowlist_required_dominates_unknown() {
let allowlist = vec![
"read".to_string(),
"mcp.unknown_thing.do_X".to_string(),
"send_email".to_string(),
];
assert_eq!(
classify_node_allowlist(&allowlist),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn classify_node_allowlist_empty_is_user_configurable() {
let empty: Vec<String> = vec![];
assert_eq!(
classify_node_allowlist(&empty),
ApprovalClassification::UserConfigurable
);
}
#[test]
fn allowlist_is_wildcard_detects_star() {
assert!(allowlist_is_wildcard(&vec!["*".to_string()]));
assert!(allowlist_is_wildcard(&vec!["**".to_string()]));
assert!(allowlist_is_wildcard(&vec!["mcp.*".to_string()]));
assert!(!allowlist_is_wildcard(&vec!["read".to_string()]));
assert!(!allowlist_is_wildcard(&Vec::<String>::new()));
}
#[test]
fn coder_merge_and_publish_always_gate() {
assert_eq!(
classify("coder.merge"),
ApprovalClassification::RequiresApproval
);
assert_eq!(
classify("coder.publish"),
ApprovalClassification::RequiresApproval
);
}
#[test]
fn whitespace_is_trimmed() {
assert_eq!(
classify(" send_email "),
ApprovalClassification::RequiresApproval
);
}
}