use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::mcp::McpToolSchema;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CapabilityClass {
Read,
Write,
Exec,
}
impl CapabilityClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Exec => "exec",
}
}
pub fn requires_approval(self) -> bool {
matches!(self, Self::Write | Self::Exec)
}
pub fn session_grant_allowed(self) -> bool {
matches!(self, Self::Read | Self::Write)
}
}
type ClassTable = HashMap<(String, String), CapabilityClass>;
fn table() -> &'static ClassTable {
static TABLE: OnceLock<ClassTable> = OnceLock::new();
TABLE.get_or_init(build_table)
}
fn build_table() -> ClassTable {
let mut t = ClassTable::new();
for tool in [
"read_text_file",
"read_file",
"read_media_file",
"read_multiple_files",
"list_directory",
"list_directory_with_sizes",
"directory_tree",
"search_files",
"get_file_info",
"list_allowed_directories",
] {
t.insert(("filesystem".into(), tool.into()), CapabilityClass::Read);
}
for tool in ["calculate", "evaluate", "calculate_expression"] {
t.insert(("calculator".into(), tool.into()), CapabilityClass::Read);
}
t.insert(("rag".into(), "search".into()), CapabilityClass::Read);
t.insert(
("rag".into(), "list_documents".into()),
CapabilityClass::Read,
);
t.insert(
("rag".into(), "index_document".into()),
CapabilityClass::Write,
);
for tool in ["memory_catalog", "memory_search"] {
t.insert(("memory".into(), tool.into()), CapabilityClass::Read);
}
for tool in ["memory_remember", "memory_forget"] {
t.insert(("memory".into(), tool.into()), CapabilityClass::Write);
}
for tool in [
"git_status",
"git_log",
"git_diff",
"git_diff_unstaged",
"git_diff_staged",
"git_show",
"git_blame",
] {
t.insert(("git".into(), tool.into()), CapabilityClass::Read);
}
for tool in [
"git_commit",
"git_add",
"git_checkout",
"git_reset",
"git_push",
"git_create_branch",
"git_branch",
] {
t.insert(("git".into(), tool.into()), CapabilityClass::Write);
}
t.insert(("fetch".into(), "fetch".into()), CapabilityClass::Read);
t.insert(
("duckduckgo".into(), "duckduckgo_web_search".into()),
CapabilityClass::Read,
);
t.insert(
("sequential-thinking".into(), "sequentialthinking".into()),
CapabilityClass::Read,
);
for tool in [
"browser_snapshot",
"browser_find",
"browser_console_messages",
"browser_network_requests",
"browser_network_request",
"browser_take_screenshot",
"browser_wait_for",
"browser_navigate",
"browser_navigate_back",
] {
t.insert(("playwright".into(), tool.into()), CapabilityClass::Read);
}
for tool in [
"browser_click",
"browser_hover",
"browser_type",
"browser_press_key",
"browser_fill_form",
"browser_select_option",
"browser_drag",
"browser_tabs",
"browser_close",
"browser_resize",
"browser_emulate_media",
"browser_handle_dialog",
] {
t.insert(("playwright".into(), tool.into()), CapabilityClass::Write);
}
for tool in [
"browser_evaluate",
"browser_run_code_unsafe",
"browser_file_upload",
"browser_drop",
] {
t.insert(("playwright".into(), tool.into()), CapabilityClass::Exec);
}
t.insert(
("vision".into(), "analyze_image".into()),
CapabilityClass::Read,
);
for tool in ["write_file", "edit_file", "create_directory", "move_file"] {
t.insert(("filesystem".into(), tool.into()), CapabilityClass::Write);
}
for tool in ["read_query", "list_tables", "describe_table"] {
t.insert(("sqlite".into(), tool.into()), CapabilityClass::Read);
}
for tool in [
"write_query",
"execute_query",
"create_table",
"modify_table",
"append_insight",
] {
t.insert(("sqlite".into(), tool.into()), CapabilityClass::Write);
}
for (server, tool) in [
("terminal", "run_command"),
("code_execution", "execute_code"),
("docker-mcp-gateway", "mcp-exec"),
("docker-mcp-gateway", "code-mode"),
("docker-mcp-gateway", "mcp-add"),
("docker-mcp-gateway", "mcp-remove"),
("docker-mcp-gateway", "mcp-config-set"),
("docker-mcp-gateway", "mcp-create-profile"),
("docker-mcp-gateway", "mcp-activate-profile"),
("docker-mcp-gateway", "mcp-find"),
] {
t.insert((server.into(), tool.into()), CapabilityClass::Exec);
}
t
}
pub fn classify(server: &str, tool: &str) -> CapabilityClass {
table()
.get(&(server.to_string(), tool.to_string()))
.copied()
.unwrap_or(CapabilityClass::Exec)
}
pub fn classify_schema(schema: &McpToolSchema) -> CapabilityClass {
classify(&schema.server, &schema.name)
}
pub fn is_vetted_auto_apply(
class: CapabilityClass,
resolved_path: Option<&std::path::Path>,
workspace_root: &std::path::Path,
) -> bool {
if class != CapabilityClass::Write {
return false;
}
let Some(path) = resolved_path else {
return false;
};
let Ok(canon_path) = path.canonicalize() else {
return false;
};
let Ok(canon_root) = workspace_root.canonicalize() else {
return false;
};
canon_path.starts_with(canon_root)
}
pub fn is_vetted_memory_write(server: &str, tool: &str) -> bool {
server == "memory" && tool == "memory_remember"
}
fn is_scope_stamped(server: &str) -> bool {
server == "memory"
}
pub fn stamp_workspace_scope(
server: &str,
tool: &str,
mut args: serde_json::Value,
workspace_id: &str,
) -> serde_json::Value {
let _ = tool;
if !is_scope_stamped(server) {
return args;
}
if !args.is_object() {
args = serde_json::json!({});
}
let obj = args.as_object_mut().expect("just ensured object");
obj.insert(
"workspace_id".to_string(),
serde_json::Value::String(workspace_id.to_string()),
);
args
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
NeedsApproval {
class: CapabilityClass,
reason: &'static str,
},
}
impl Decision {
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allow)
}
}
type GrantKey = (String, String, String);
fn session_grants() -> &'static Mutex<HashMap<GrantKey, ()>> {
static GRANTS: OnceLock<Mutex<HashMap<GrantKey, ()>>> = OnceLock::new();
GRANTS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn grant_for_session(
workspace_id: &str,
server: &str,
tool: &str,
class: CapabilityClass,
) -> bool {
if !class.session_grant_allowed() {
return false;
}
if let Ok(mut grants) = session_grants().lock() {
grants.insert(
(
workspace_id.to_string(),
server.to_string(),
tool.to_string(),
),
(),
);
return true;
}
false
}
pub fn has_session_grant(workspace_id: &str, server: &str, tool: &str) -> bool {
session_grants()
.lock()
.map(|grants| {
grants.contains_key(&(
workspace_id.to_string(),
server.to_string(),
tool.to_string(),
))
})
.unwrap_or(false)
}
pub fn decide(
workspace_id: &str,
server: &str,
tool: &str,
resolved_path: Option<&std::path::Path>,
workspace_root: &std::path::Path,
) -> Decision {
let class = classify(server, tool);
if !class.requires_approval() {
return Decision::Allow;
}
if is_vetted_auto_apply(class, resolved_path, workspace_root) {
return Decision::Allow;
}
if class == CapabilityClass::Write && is_vetted_memory_write(server, tool) {
return Decision::Allow;
}
if class.session_grant_allowed() && has_session_grant(workspace_id, server, tool) {
return Decision::Allow;
}
let reason = match class {
CapabilityClass::Exec => "exec tool requires approval",
CapabilityClass::Write => "write tool requires approval",
CapabilityClass::Read => "read tool requires approval",
};
Decision::NeedsApproval { class, reason }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_tool_defaults_to_exec() {
assert_eq!(
classify("brand-new-server", "do_thing"),
CapabilityClass::Exec
);
}
#[test]
fn known_server_unknown_tool_still_defaults_to_exec() {
assert_eq!(
classify("filesystem", "delete_everything"),
CapabilityClass::Exec
);
}
#[test]
fn filesystem_reads_and_writes_are_distinguished() {
assert_eq!(
classify("filesystem", "read_text_file"),
CapabilityClass::Read
);
assert_eq!(classify("filesystem", "write_file"), CapabilityClass::Write);
}
#[test]
fn exec_is_never_session_grantable() {
assert!(!CapabilityClass::Exec.session_grant_allowed());
assert!(CapabilityClass::Write.session_grant_allowed());
assert!(CapabilityClass::Read.session_grant_allowed());
}
#[test]
fn read_does_not_require_approval() {
assert!(!CapabilityClass::Read.requires_approval());
assert!(CapabilityClass::Write.requires_approval());
assert!(CapabilityClass::Exec.requires_approval());
}
#[test]
fn exec_is_never_auto_applied() {
let root = std::env::temp_dir();
let inside = root.join("file.txt");
assert!(!is_vetted_auto_apply(
CapabilityClass::Exec,
Some(&inside),
&root
));
}
#[test]
fn read_is_never_auto_applied_as_a_write() {
let root = std::env::temp_dir();
let inside = root.join("file.txt");
assert!(!is_vetted_auto_apply(
CapabilityClass::Read,
Some(&inside),
&root
));
}
#[test]
fn write_inside_workspace_is_auto_applicable() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("notes.md");
std::fs::write(&file, "hello").unwrap();
assert!(is_vetted_auto_apply(
CapabilityClass::Write,
Some(&file),
dir.path()
));
}
#[test]
fn write_outside_workspace_is_refused() {
let dir = tempfile::tempdir().unwrap();
let outside = std::env::temp_dir().join("definitely-outside-workspace.txt");
std::fs::write(&outside, "nope").unwrap();
assert!(!is_vetted_auto_apply(
CapabilityClass::Write,
Some(&outside),
dir.path()
));
let _ = std::fs::remove_file(&outside);
}
#[test]
fn write_without_a_resolved_path_is_refused() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_vetted_auto_apply(
CapabilityClass::Write,
None,
dir.path()
));
}
#[test]
fn exec_is_gated_even_with_a_session_grant() {
let ws = "ws_exec_grant_test";
assert!(!grant_for_session(
ws,
"terminal",
"run_command",
CapabilityClass::Exec
));
assert!(!has_session_grant(ws, "terminal", "run_command"));
}
#[test]
fn write_grant_is_scoped_to_workspace_and_tool() {
let ws_a = "ws_grant_a";
let ws_b = "ws_grant_b";
assert!(grant_for_session(
ws_a,
"filesystem",
"write_file",
CapabilityClass::Write
));
assert!(has_session_grant(ws_a, "filesystem", "write_file"));
assert!(!has_session_grant(ws_b, "filesystem", "write_file"));
assert!(!has_session_grant(ws_a, "filesystem", "edit_file"));
}
#[test]
fn read_tools_are_allowed_without_approval() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
decide(
"ws_read_test",
"filesystem",
"read_text_file",
None,
dir.path()
),
Decision::Allow
);
}
#[test]
fn write_outside_workspace_stays_gated_under_a_grant() {
let dir = tempfile::tempdir().unwrap();
let outside = std::env::temp_dir().join("grant-outside-workspace.txt");
std::fs::write(&outside, "nope").unwrap();
assert!(grant_for_session(
"ws_outside_test",
"filesystem",
"write_file",
CapabilityClass::Write
));
assert_eq!(
decide(
"ws_outside_test",
"filesystem",
"write_file",
Some(&outside),
dir.path()
),
Decision::Allow,
"an explicit session grant permits the call; path containment is enforced by resolve_relative at dispatch"
);
let _ = std::fs::remove_file(&outside);
}
#[test]
fn unknown_tool_is_gated_as_exec() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
decide("ws_unknown_test", "mystery", "act", None, dir.path()),
Decision::NeedsApproval {
class: CapabilityClass::Exec,
..
}
));
}
#[test]
fn stamp_overrides_a_model_supplied_workspace() {
let args = serde_json::json!({ "workspace_id": "ws_someone_else", "query": "salary" });
let stamped = stamp_workspace_scope("memory", "memory_search", args, "ws_real");
assert_eq!(stamped["workspace_id"], "ws_real");
assert_eq!(stamped["query"], "salary", "other args are preserved");
}
#[test]
fn stamp_adds_the_scope_when_absent() {
let stamped = stamp_workspace_scope(
"memory",
"memory_remember",
serde_json::json!({ "title": "t" }),
"ws_real",
);
assert_eq!(stamped["workspace_id"], "ws_real");
}
#[test]
fn stamp_replaces_a_non_object_argument_bag() {
let stamped = stamp_workspace_scope(
"memory",
"memory_search",
serde_json::json!("oops"),
"ws_real",
);
assert_eq!(stamped["workspace_id"], "ws_real");
}
#[test]
fn stamp_leaves_unscoped_servers_alone() {
let args = serde_json::json!({ "expression": "2+2" });
let out = stamp_workspace_scope("calculator", "calculate", args.clone(), "ws_real");
assert_eq!(out, args);
assert!(out.get("workspace_id").is_none());
}
#[test]
fn scoped_memory_reads_are_reads_and_writes_are_writes() {
assert_eq!(classify("memory", "memory_catalog"), CapabilityClass::Read);
assert_eq!(classify("memory", "memory_search"), CapabilityClass::Read);
assert_eq!(
classify("memory", "memory_remember"),
CapabilityClass::Write
);
assert_eq!(classify("memory", "memory_forget"), CapabilityClass::Write);
}
const REAL_TOOLS: &[(&str, &str, CapabilityClass)] = &[
("filesystem", "read_text_file", CapabilityClass::Read),
("filesystem", "read_file", CapabilityClass::Read),
("filesystem", "read_media_file", CapabilityClass::Read),
("filesystem", "read_multiple_files", CapabilityClass::Read),
("filesystem", "list_directory", CapabilityClass::Read),
(
"filesystem",
"list_directory_with_sizes",
CapabilityClass::Read,
),
("filesystem", "directory_tree", CapabilityClass::Read),
("filesystem", "search_files", CapabilityClass::Read),
("filesystem", "get_file_info", CapabilityClass::Read),
(
"filesystem",
"list_allowed_directories",
CapabilityClass::Read,
),
("filesystem", "write_file", CapabilityClass::Write),
("filesystem", "edit_file", CapabilityClass::Write),
("filesystem", "create_directory", CapabilityClass::Write),
("filesystem", "move_file", CapabilityClass::Write),
("calculator", "calculate", CapabilityClass::Read),
("fetch", "fetch", CapabilityClass::Read),
("git", "git_status", CapabilityClass::Read),
("git", "git_log", CapabilityClass::Read),
("git", "git_diff", CapabilityClass::Read),
("git", "git_diff_unstaged", CapabilityClass::Read),
("git", "git_diff_staged", CapabilityClass::Read),
("git", "git_show", CapabilityClass::Read),
("git", "git_blame", CapabilityClass::Read),
("git", "git_add", CapabilityClass::Write),
("git", "git_commit", CapabilityClass::Write),
("git", "git_checkout", CapabilityClass::Write),
("git", "git_reset", CapabilityClass::Write),
("git", "git_push", CapabilityClass::Write),
("git", "git_create_branch", CapabilityClass::Write),
("git", "git_branch", CapabilityClass::Write),
("sqlite", "read_query", CapabilityClass::Read),
("sqlite", "list_tables", CapabilityClass::Read),
("sqlite", "describe_table", CapabilityClass::Read),
("sqlite", "write_query", CapabilityClass::Write),
("sqlite", "create_table", CapabilityClass::Write),
("sqlite", "append_insight", CapabilityClass::Write),
("rag", "search", CapabilityClass::Read),
("rag", "index_document", CapabilityClass::Write),
("vision", "analyze_image", CapabilityClass::Read),
(
"sequential-thinking",
"sequentialthinking",
CapabilityClass::Read,
),
("memory", "memory_catalog", CapabilityClass::Read),
("memory", "memory_search", CapabilityClass::Read),
("memory", "memory_remember", CapabilityClass::Write),
("duckduckgo", "duckduckgo_web_search", CapabilityClass::Read),
("playwright", "browser_snapshot", CapabilityClass::Read),
("playwright", "browser_find", CapabilityClass::Read),
(
"playwright",
"browser_console_messages",
CapabilityClass::Read,
),
(
"playwright",
"browser_network_requests",
CapabilityClass::Read,
),
(
"playwright",
"browser_network_request",
CapabilityClass::Read,
),
(
"playwright",
"browser_take_screenshot",
CapabilityClass::Read,
),
("playwright", "browser_wait_for", CapabilityClass::Read),
("playwright", "browser_navigate", CapabilityClass::Read),
("playwright", "browser_navigate_back", CapabilityClass::Read),
("playwright", "browser_click", CapabilityClass::Write),
("playwright", "browser_hover", CapabilityClass::Write),
("playwright", "browser_type", CapabilityClass::Write),
("playwright", "browser_press_key", CapabilityClass::Write),
("playwright", "browser_fill_form", CapabilityClass::Write),
(
"playwright",
"browser_select_option",
CapabilityClass::Write,
),
("playwright", "browser_drag", CapabilityClass::Write),
("playwright", "browser_tabs", CapabilityClass::Write),
("playwright", "browser_close", CapabilityClass::Write),
("playwright", "browser_resize", CapabilityClass::Write),
(
"playwright",
"browser_emulate_media",
CapabilityClass::Write,
),
(
"playwright",
"browser_handle_dialog",
CapabilityClass::Write,
),
("playwright", "browser_evaluate", CapabilityClass::Exec),
(
"playwright",
"browser_run_code_unsafe",
CapabilityClass::Exec,
),
("playwright", "browser_file_upload", CapabilityClass::Exec),
("playwright", "browser_drop", CapabilityClass::Exec),
];
#[test]
fn every_real_tool_is_classified_as_expected() {
let mut wrong = Vec::new();
for (server, tool, expected) in REAL_TOOLS {
let actual = classify(server, tool);
if actual != *expected {
wrong.push(format!(
"{server}.{tool}: expected {expected:?}, got {actual:?}"
));
}
}
assert!(
wrong.is_empty(),
"capability table disagrees with the real tool set:\n {}",
wrong.join("\n ")
);
}
#[test]
fn every_browser_tool_is_classified() {
let live = [
"browser_close",
"browser_resize",
"browser_console_messages",
"browser_handle_dialog",
"browser_emulate_media",
"browser_evaluate",
"browser_file_upload",
"browser_drop",
"browser_find",
"browser_fill_form",
"browser_press_key",
"browser_type",
"browser_navigate",
"browser_navigate_back",
"browser_network_requests",
"browser_network_request",
"browser_run_code_unsafe",
"browser_take_screenshot",
"browser_snapshot",
"browser_click",
"browser_drag",
"browser_hover",
"browser_select_option",
"browser_tabs",
"browser_wait_for",
];
let mut unclassified = Vec::new();
for tool in live {
if !table().contains_key(&("playwright".to_string(), tool.to_string())) {
unclassified.push(tool);
}
}
assert!(
unclassified.is_empty(),
"browser tools missing from the capability table (they would all fall \
through to Exec): {unclassified:?}"
);
}
#[test]
fn browser_code_and_exfil_tools_stay_exec() {
for tool in [
"browser_evaluate",
"browser_run_code_unsafe",
"browser_file_upload",
"browser_drop",
] {
assert_eq!(
classify("playwright", tool),
CapabilityClass::Exec,
"{tool} must stay Exec: it executes code or ships local files off-box"
);
assert!(
!classify("playwright", tool).session_grant_allowed(),
"{tool} must not be session-grantable"
);
}
}
#[test]
fn browser_observation_tools_are_reads() {
for tool in [
"browser_snapshot",
"browser_find",
"browser_navigate",
"browser_navigate_back",
"browser_console_messages",
"browser_network_requests",
"browser_network_request",
"browser_take_screenshot",
"browser_wait_for",
] {
assert_eq!(
classify("playwright", tool),
CapabilityClass::Read,
"{tool} is observation and must not demand approval"
);
}
}
#[test]
fn no_ordinary_operation_is_left_to_the_exec_default() {
for (server, tool) in [
("git", "git_diff_unstaged"),
("git", "git_diff_staged"),
("sqlite", "list_tables"),
("sqlite", "describe_table"),
("fetch", "fetch"),
("filesystem", "read_file"),
] {
assert_ne!(
classify(server, tool),
CapabilityClass::Exec,
"{server}.{tool} fell through to Exec and would demand approval"
);
}
for tool in ["git_create_branch", "git_branch"] {
assert_eq!(classify("git", tool), CapabilityClass::Write);
}
assert_eq!(classify("sqlite", "append_insight"), CapabilityClass::Write);
}
#[test]
fn memory_remember_auto_applies() {
assert!(is_vetted_memory_write("memory", "memory_remember"));
}
#[test]
fn memory_forget_is_never_auto_applied() {
assert!(!is_vetted_memory_write("memory", "memory_forget"));
let d = decide(
"ws",
"memory",
"memory_forget",
None,
std::path::Path::new("."),
);
assert!(
matches!(d, Decision::NeedsApproval { .. }),
"forget must still require approval, got {d:?}"
);
}
#[test]
fn only_the_memory_server_gets_the_exemption() {
assert!(!is_vetted_memory_write("filesystem", "memory_remember"));
assert!(!is_vetted_memory_write(
"brand-new-server",
"memory_remember"
));
}
#[test]
fn the_exemption_does_not_reach_other_tools_on_the_memory_server() {
for tool in ["memory_search", "memory_catalog"] {
assert!(!is_vetted_memory_write("memory", tool), "{tool}");
}
}
#[test]
fn a_write_on_another_server_still_needs_approval() {
let d = decide(
"ws",
"rag",
"index_document",
None,
std::path::Path::new("."),
);
assert!(matches!(d, Decision::NeedsApproval { .. }), "{d:?}");
}
#[test]
fn the_exemption_does_not_reach_exec() {
assert!(!is_vetted_memory_write("memory", "memory_shell"));
let d = decide(
"ws",
"memory",
"memory_shell",
None,
std::path::Path::new("."),
);
assert!(matches!(d, Decision::NeedsApproval { .. }), "{d:?}");
}
#[test]
fn the_exemption_is_workspace_scoped_not_path_scoped() {
let d = decide(
"ws_a",
"memory",
"memory_remember",
None,
std::path::Path::new("."),
);
assert!(d.is_allowed(), "a stamped-scope memory write should apply");
}
}