use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Tier {
Read,
Write,
All,
}
impl Tier {
pub fn parse(raw: &str) -> Option<Tier> {
match raw.trim().to_lowercase().replace('-', "_").as_str() {
"read" | "read_only" | "readonly" => Some(Tier::Read),
"write" => Some(Tier::Write),
"all" | "full" | "full_auto" | "fullauto" => Some(Tier::All),
_ => None,
}
}
pub fn parse_or_default(raw: &str) -> Tier {
Tier::parse(raw).unwrap_or(Tier::Read)
}
pub fn label(self) -> &'static str {
match self {
Tier::Read => "read",
Tier::Write => "write",
Tier::All => "all",
}
}
pub fn description(self) -> &'static str {
match self {
Tier::Read => "read-only tool batches auto-approve; writes/shell/network prompt",
Tier::Write => "reads + workspace file writes auto-approve; shell/network prompt",
Tier::All => {
"every tool auto-approves — no prompts (per-tool 'ask' pins and gateway-disabled tools still gate)"
}
}
}
}
const READ_TOOLS: &[&str] = &[
"analyze_code",
"analyze_media", "list_files",
"read_file",
"search_files",
"skim_files",
"skim_folders",
"skim_url",
"skim_websearch",
"web_search",
];
const WRITE_TOOLS: &[&str] = &["write_file", "edit_file"];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolClass {
pub name: String,
pub approval: Option<String>,
pub tier: Option<String>,
pub served_disabled: bool,
pub enable_gate: String,
pub risk_rank: Option<u8>,
}
fn served_disabled(name: &str, classes: &[ToolClass]) -> bool {
classes.iter().any(|c| c.name == name && c.served_disabled)
}
pub fn server_tier(name: &str, approval: &str, risk_rank: Option<u8>) -> Tier {
let approval_tier = match approval.trim().to_lowercase().as_str() {
"auto" => Tier::Read,
_ if name == "write_file" || name == "edit_file" => Tier::Write,
_ => Tier::All,
};
let band_floor = match risk_rank {
Some(1) => Tier::Read,
Some(2) => Tier::Write,
Some(_) => Tier::All, None => Tier::Read, };
approval_tier.max(band_floor)
}
fn server_facts<'a>(name: &str, classes: &'a [ToolClass]) -> Option<(&'a str, Option<u8>)> {
classes
.iter()
.find(|c| c.name == name)
.and_then(|c| c.approval.as_deref().map(|a| (a, c.risk_rank)))
}
pub fn classify_call(name: &str) -> Tier {
let name = name.trim();
if READ_TOOLS.contains(&name) {
return Tier::Read;
}
if WRITE_TOOLS.contains(&name) {
return Tier::Write;
}
Tier::All
}
pub fn classify_call_with(name: &str, classes: &[ToolClass]) -> Tier {
let name = name.trim();
if let Some((approval, rank)) = server_facts(name, classes) {
return server_tier(name, approval, rank);
}
classify_call(name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClassSource {
Server,
NameTable,
}
pub fn classify_source(name: &str, classes: &[ToolClass]) -> ClassSource {
let name = name.trim();
if server_facts(name, classes).is_some() {
return ClassSource::Server;
}
ClassSource::NameTable
}
pub fn batch_name_table_names(tool_calls: &[Value], classes: &[ToolClass]) -> Vec<String> {
let mut out: Vec<String> = tool_calls
.iter()
.filter_map(|tc| {
let name = tc
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if name.is_empty() {
return Some("(unnamed)".to_string());
}
match classify_source(&name, classes) {
ClassSource::NameTable => Some(name),
ClassSource::Server => None,
}
})
.collect();
out.sort();
out.dedup();
out
}
pub fn batch_tier(tool_calls: &[Value]) -> Tier {
batch_tier_with(tool_calls, &[])
}
pub fn batch_tier_with(tool_calls: &[Value], classes: &[ToolClass]) -> Tier {
if tool_calls.is_empty() {
return Tier::All;
}
tool_calls
.iter()
.map(|tc| {
let name = tc.get("name").and_then(Value::as_str).unwrap_or("");
if name.is_empty() {
return Tier::All; }
classify_call_with(name, classes)
})
.max()
.unwrap_or(Tier::All)
}
pub fn call_auto_approves(
name: &str,
accepted: Tier,
overrides: &[(String, String)],
classes: &[ToolClass],
) -> bool {
if served_disabled(name, classes) {
return false;
}
if let Some((_, decision)) = overrides.iter().find(|(n, _)| n == name) {
match decision.as_str() {
"auto" => return true,
"ask" => return false,
_ => {} }
}
classify_call_with(name, classes) <= accepted
}
pub fn batch_auto_approves(
tool_calls: &[Value],
accepted_raw: &str,
overrides: &[(String, String)],
) -> bool {
batch_auto_approves_with(tool_calls, accepted_raw, overrides, &[])
}
pub fn batch_auto_approves_with(
tool_calls: &[Value],
accepted_raw: &str,
overrides: &[(String, String)],
classes: &[ToolClass],
) -> bool {
let accepted = Tier::parse_or_default(accepted_raw);
if tool_calls.is_empty() {
return false; }
tool_calls.iter().all(|tc| {
let name = tc.get("name").and_then(Value::as_str).unwrap_or("");
!name.is_empty() && call_auto_approves(name, accepted, overrides, classes)
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunToolPolicy {
pub auto_approve_tools: Vec<String>,
pub require_approval_tools: Vec<String>,
}
impl RunToolPolicy {
pub fn is_empty(&self) -> bool {
self.auto_approve_tools.is_empty() && self.require_approval_tools.is_empty()
}
}
pub fn expand_run_policy(
classes: &[ToolClass],
accepted_raw: &str,
overrides: &[(String, String)],
) -> RunToolPolicy {
let accepted = Tier::parse_or_default(accepted_raw);
let mut auto: Vec<String> = Vec::new();
let mut require: Vec<String> = Vec::new();
for c in classes {
let name = c.name.trim();
if name.is_empty() {
continue;
}
if c.served_disabled {
require.push(name.to_string());
continue;
}
let pin = overrides
.iter()
.find(|(n, _)| n == name)
.map(|(_, d)| d.as_str());
match pin {
Some("auto") => auto.push(name.to_string()),
Some("ask") => require.push(name.to_string()),
_ => {
if classify_call_with(name, classes) <= accepted {
auto.push(name.to_string());
}
}
}
}
auto.sort();
auto.dedup();
require.sort();
require.dedup();
RunToolPolicy {
auto_approve_tools: auto,
require_approval_tools: require,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tier_order_is_the_legibility_gradient() {
assert!(Tier::Read < Tier::Write);
assert!(Tier::Write < Tier::All);
}
#[test]
fn tier_parse_accepts_labels_refuses_garbage() {
assert_eq!(Tier::parse("read"), Some(Tier::Read));
assert_eq!(Tier::parse("read-only"), Some(Tier::Read));
assert_eq!(Tier::parse("WRITE"), Some(Tier::Write));
assert_eq!(Tier::parse("all"), Some(Tier::All));
assert_eq!(Tier::parse("full-auto"), Some(Tier::All));
assert_eq!(Tier::parse("garbage"), None);
assert_eq!(Tier::parse(""), None);
assert_eq!(Tier::parse_or_default("garbage"), Tier::Read);
assert_eq!(Tier::parse_or_default(""), Tier::Read);
}
#[test]
fn read_tools_classify_read_writes_write_rest_all() {
for name in [
"read_file",
"list_files",
"search_files",
"web_search",
"skim_url",
] {
assert_eq!(classify_call(name), Tier::Read, "{name}");
}
for name in ["write_file", "edit_file"] {
assert_eq!(classify_call(name), Tier::Write, "{name}");
}
for name in [
"fetch_url",
"shell_exec",
"shell_write_stdin",
"shell_close",
] {
assert_eq!(classify_call(name), Tier::All, "{name}");
}
}
#[test]
fn unknown_tools_fail_closed_to_all() {
assert_eq!(classify_call("mcp::server::delete_everything"), Tier::All);
assert_eq!(classify_call("brand_new_tool"), Tier::All);
assert_eq!(classify_call(""), Tier::All);
}
#[test]
fn execute_command_is_always_all_in_the_name_table() {
assert_eq!(classify_call("execute_command"), Tier::All);
assert_eq!(classify_call("shell_exec"), Tier::All);
}
fn call(name: &str) -> Value {
json!({"name": name, "arguments": {}})
}
#[test]
fn batch_tier_is_the_max_over_calls() {
assert_eq!(batch_tier(&[call("read_file")]), Tier::Read);
assert_eq!(
batch_tier(&[call("read_file"), call("write_file")]),
Tier::Write
);
assert_eq!(
batch_tier(&[call("read_file"), call("execute_command")]),
Tier::All
);
assert_eq!(batch_tier(&[]), Tier::All);
assert_eq!(batch_tier(&[json!({"arguments": {}})]), Tier::All);
}
#[test]
fn batch_auto_respects_accepted_tier() {
let none: &[(String, String)] = &[];
assert!(batch_auto_approves(&[call("read_file")], "read", none));
assert!(!batch_auto_approves(&[call("write_file")], "read", none));
assert!(batch_auto_approves(&[call("write_file")], "write", none));
assert!(!batch_auto_approves(&[call("fetch_url")], "write", none));
assert!(batch_auto_approves(
&[call("fetch_url"), call("execute_command")],
"all",
none
));
assert!(!batch_auto_approves(
&[call("read_file"), call("write_file")],
"read",
none
));
assert!(!batch_auto_approves(&[call("write_file")], "banana", none));
assert!(batch_auto_approves(&[call("read_file")], "", none));
assert!(!batch_auto_approves(&[json!({})], "all", none));
assert!(!batch_auto_approves(&[], "all", none));
}
#[test]
fn git_batches_never_auto_approve_client_side_below_all() {
let batch = vec![json!({"name": "execute_command",
"arguments": {"command": "git log --oneline -n 20"}})];
let none: &[(String, String)] = &[];
assert!(!batch_auto_approves(&batch, "read", none));
assert!(!batch_auto_approves(&batch, "write", none));
assert!(batch_auto_approves(&batch, "all", none));
let push = vec![json!({"name": "execute_command",
"arguments": {"command": "git push"}})];
assert!(!batch_auto_approves(&push, "write", none));
}
#[test]
fn overrides_pin_per_tool_decisions() {
let pins = vec![
("fetch_url".to_string(), "auto".to_string()),
("read_file".to_string(), "ask".to_string()),
("write_file".to_string(), "nonsense".to_string()),
];
assert!(batch_auto_approves(&[call("fetch_url")], "read", &pins));
assert!(!batch_auto_approves(&[call("read_file")], "all", &pins));
assert!(batch_auto_approves(&[call("write_file")], "write", &pins));
}
fn cls(name: &str, approval: &str) -> ToolClass {
ToolClass {
name: name.into(),
approval: Some(approval.into()),
tier: Some("tier2_world".into()),
..Default::default()
}
}
#[test]
fn server_tier_maps_approval_onto_the_gradient() {
assert_eq!(server_tier("read_file", "auto", None), Tier::Read);
assert_eq!(server_tier("read_file", "AUTO", None), Tier::Read); assert_eq!(server_tier("write_file", "ask", None), Tier::Write);
assert_eq!(server_tier("edit_file", "ask", None), Tier::Write);
assert_eq!(server_tier("execute_command", "ask", None), Tier::All);
assert_eq!(server_tier("mystery", "ask", None), Tier::All);
assert_eq!(server_tier("mystery", "garbage", None), Tier::All); }
#[test]
fn rank_band_floors_the_served_tier_approval_only_tightens() {
assert_eq!(server_tier("telegram_send", "auto", Some(3)), Tier::All);
assert_eq!(server_tier("agora_post", "auto", Some(3)), Tier::All);
assert_eq!(server_tier("analyze_media", "ask", Some(2)), Tier::All);
assert_eq!(server_tier("read_file", "auto", Some(1)), Tier::Read);
assert_eq!(server_tier("write_file", "ask", Some(2)), Tier::Write);
assert_eq!(server_tier("execute_command", "ask", Some(4)), Tier::All);
assert_eq!(server_tier("mystery", "auto", Some(9)), Tier::All);
}
#[test]
fn server_truth_beats_the_name_table() {
let classes = vec![cls("mcp::search", "auto"), cls("execute_command", "ask")];
assert_eq!(classify_call_with("mcp::search", &classes), Tier::Read);
assert_eq!(classify_call("mcp::search"), Tier::All);
assert!(batch_auto_approves_with(
&[call("mcp::search")],
"read",
&[],
&classes
));
assert!(!batch_auto_approves(&[call("mcp::search")], "read", &[]));
}
#[test]
fn empty_classes_reproduce_the_name_table_exactly() {
for (calls, tier) in [
(vec![call("read_file")], "read"),
(vec![call("write_file")], "read"),
(vec![call("write_file")], "write"),
(vec![call("fetch_url")], "all"),
] {
assert_eq!(
batch_auto_approves_with(&calls, tier, &[], &[]),
batch_auto_approves(&calls, tier, &[]),
"delegation mismatch at {tier}"
);
}
}
fn inventory() -> Vec<ToolClass> {
[
"read_file",
"list_files",
"write_file",
"edit_file",
"execute_command",
"fetch_url",
]
.iter()
.map(|n| ToolClass {
name: (*n).into(),
..Default::default()
})
.collect()
}
#[test]
fn expand_run_policy_by_tier_over_the_name_table() {
let none: &[(String, String)] = &[];
let read = expand_run_policy(&inventory(), "read", none);
assert_eq!(read.auto_approve_tools, vec!["list_files", "read_file"]);
assert!(read.require_approval_tools.is_empty());
let write = expand_run_policy(&inventory(), "write", none);
assert_eq!(
write.auto_approve_tools,
vec!["edit_file", "list_files", "read_file", "write_file"]
);
let all = expand_run_policy(&inventory(), "all", none);
assert_eq!(
all.auto_approve_tools,
vec![
"edit_file",
"execute_command",
"fetch_url",
"list_files",
"read_file",
"write_file"
]
);
}
#[test]
fn expand_run_policy_rides_overrides_both_directions() {
let pins = vec![
("fetch_url".to_string(), "auto".to_string()),
("read_file".to_string(), "ask".to_string()),
];
let p = expand_run_policy(&inventory(), "read", &pins);
assert!(p.auto_approve_tools.contains(&"fetch_url".to_string()));
assert!(!p.auto_approve_tools.contains(&"read_file".to_string()));
assert_eq!(p.require_approval_tools, vec!["read_file"]);
assert!(p.auto_approve_tools.contains(&"list_files".to_string()));
}
#[test]
fn expand_run_policy_prefers_server_truth_when_served() {
let mut inv = inventory();
inv.push(cls("mcp::search", "auto"));
let p = expand_run_policy(&inv, "read", &[]);
assert!(p.auto_approve_tools.contains(&"mcp::search".to_string()));
}
#[test]
fn served_disabled_rows_never_auto_approve() {
let disabled_row = |name: &str| ToolClass {
name: name.into(),
approval: Some("auto".into()),
tier: Some("tier2_world".into()),
served_disabled: true,
enable_gate: "SOME_GATE".into(),
..Default::default()
};
let classes = vec![cls("read_file", "auto"), disabled_row("send_email")];
assert!(!batch_auto_approves_with(
&[call("send_email")],
"all",
&[],
&classes
));
let stale_pin = vec![("send_email".to_string(), "auto".to_string())];
assert!(!batch_auto_approves_with(
&[call("send_email")],
"all",
&stale_pin,
&classes
));
assert!(!batch_auto_approves_with(
&[call("read_file"), call("send_email")],
"all",
&[],
&classes
));
let p = expand_run_policy(&classes, "all", &stale_pin);
assert!(!p.auto_approve_tools.contains(&"send_email".to_string()));
assert_eq!(p.require_approval_tools, vec!["send_email"]);
assert!(p.auto_approve_tools.contains(&"read_file".to_string()));
}
#[test]
fn expand_run_policy_empty_inventory_is_empty() {
let p = expand_run_policy(&[], "all", &[]);
assert!(p.is_empty());
}
#[test]
fn classify_source_mirrors_the_decision_order() {
let classes = vec![cls("mcp::search", "auto"), cls("execute_command", "ask")];
assert_eq!(
classify_source("mcp::search", &classes),
ClassSource::Server
);
assert_eq!(
classify_source("execute_command", &classes),
ClassSource::Server
);
assert_eq!(
classify_source("execute_command", &classes),
ClassSource::Server
);
assert_eq!(
classify_source("read_file", &classes),
ClassSource::NameTable
);
assert_eq!(
classify_source("brand_new_tool", &[]),
ClassSource::NameTable
);
}
#[test]
fn batch_name_table_names_lists_only_fallback_classified_calls() {
let classes = vec![cls("mcp::search", "auto")];
let batch = vec![
call("mcp::search"), call("read_file"), call("read_file"), json!({"name": "execute_command",
"arguments": {"command": "git status"}}), json!({"arguments": {}}), ];
assert_eq!(
batch_name_table_names(&batch, &classes),
vec![
"(unnamed)".to_string(),
"execute_command".to_string(),
"read_file".to_string()
]
);
assert!(batch_name_table_names(&[call("mcp::search")], &classes).is_empty());
assert_eq!(
batch_name_table_names(&[call("write_file")], &[]),
vec!["write_file".to_string()]
);
}
}