use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use meerkat_core::{ToolName, ToolNameSet};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolMode {
#[default]
AllowAll,
DenyAll,
AllowList,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ToolPolicyLayer {
#[serde(default)]
pub mode: Option<ToolMode>,
#[serde(default)]
pub enable: ToolNameSet,
#[serde(default)]
pub disable: ToolNameSet,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct EnforcedToolPolicy {
#[serde(default)]
pub allow: ToolNameSet,
#[serde(default)]
pub deny: ToolNameSet,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BuiltinToolConfig {
#[serde(default)]
pub policy: ToolPolicyLayer,
#[serde(default)]
pub enforced: EnforcedToolPolicy,
}
impl ToolPolicyLayer {
pub fn new() -> Self {
Self::default()
}
pub fn with_mode(mut self, mode: ToolMode) -> Self {
self.mode = Some(mode);
self
}
pub fn enable_tool(mut self, name: impl Into<ToolName>) -> Self {
self.enable.insert(name);
self
}
pub fn disable_tool(mut self, name: impl Into<ToolName>) -> Self {
self.disable.insert(name);
self
}
}
#[derive(Clone, Debug)]
pub struct ResolvedToolPolicy {
mode: ToolMode,
enabled: ToolNameSet,
disabled: ToolNameSet,
}
impl ResolvedToolPolicy {
pub fn is_enabled(&self, tool_name: &str, default_enabled: bool) -> bool {
if self.disabled.contains(tool_name) {
return false;
}
if self.enabled.contains(tool_name) {
return true;
}
match self.mode {
ToolMode::AllowAll => default_enabled,
ToolMode::DenyAll | ToolMode::AllowList => false,
}
}
pub fn mode(&self) -> &ToolMode {
&self.mode
}
pub fn enabled(&self) -> &ToolNameSet {
&self.enabled
}
pub fn disabled(&self) -> &ToolNameSet {
&self.disabled
}
}
pub fn merge_policy_layers(layers: &[ToolPolicyLayer]) -> ResolvedToolPolicy {
let mut mode = ToolMode::AllowAll;
let mut tool_states: HashMap<ToolName, bool> = HashMap::new();
for layer in layers {
if let Some(m) = &layer.mode {
mode = m.clone();
}
for tool in &layer.enable {
tool_states.insert(tool.clone(), true);
}
for tool in &layer.disable {
tool_states.insert(tool.clone(), false);
}
}
let enabled: ToolNameSet = tool_states
.iter()
.filter(|(_, v)| **v)
.map(|(k, _)| k.clone())
.collect();
let disabled: ToolNameSet = tool_states
.iter()
.filter(|(_, v)| !**v)
.map(|(k, _)| k.clone())
.collect();
ResolvedToolPolicy {
mode,
enabled,
disabled,
}
}
pub fn apply_enforced_policy(
mut resolved: ResolvedToolPolicy,
enforced: &EnforcedToolPolicy,
) -> ResolvedToolPolicy {
for tool in &enforced.deny {
resolved.disabled.insert(tool.clone());
resolved.enabled.remove(tool.as_str());
}
if !enforced.allow.is_empty() {
resolved
.enabled
.retain(|tool| enforced.allow.contains(tool.as_str()));
resolved.mode = ToolMode::AllowList;
}
resolved
}
impl BuiltinToolConfig {
pub fn resolve(&self) -> ResolvedToolPolicy {
let resolved = merge_policy_layers(std::slice::from_ref(&self.policy));
apply_enforced_policy(resolved, &self.enforced)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_tool_mode_serde() {
let allow_all = ToolMode::AllowAll;
let json = serde_json::to_string(&allow_all).unwrap();
assert_eq!(json, "\"allow_all\"");
let deny_all = ToolMode::DenyAll;
let json = serde_json::to_string(&deny_all).unwrap();
assert_eq!(json, "\"deny_all\"");
let allow_list = ToolMode::AllowList;
let json = serde_json::to_string(&allow_list).unwrap();
assert_eq!(json, "\"allow_list\"");
let mode: ToolMode = serde_json::from_str("\"allow_all\"").unwrap();
assert_eq!(mode, ToolMode::AllowAll);
let mode: ToolMode = serde_json::from_str("\"deny_all\"").unwrap();
assert_eq!(mode, ToolMode::DenyAll);
let mode: ToolMode = serde_json::from_str("\"allow_list\"").unwrap();
assert_eq!(mode, ToolMode::AllowList);
}
#[test]
fn test_tool_policy_layer_serde() {
let layer = ToolPolicyLayer {
mode: Some(ToolMode::DenyAll),
enable: ["read_file", "write_file"]
.into_iter()
.map(String::from)
.collect(),
disable: ["bash"].into_iter().map(String::from).collect(),
};
let json = serde_json::to_string(&layer).unwrap();
let deserialized: ToolPolicyLayer = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.mode, Some(ToolMode::DenyAll));
assert!(deserialized.enable.contains("read_file"));
assert!(deserialized.enable.contains("write_file"));
assert!(deserialized.disable.contains("bash"));
let empty_layer: ToolPolicyLayer = serde_json::from_str("{}").unwrap();
assert_eq!(empty_layer.mode, None);
assert!(empty_layer.enable.is_empty());
assert!(empty_layer.disable.is_empty());
}
#[test]
fn test_enforced_policy_serde() {
let policy = EnforcedToolPolicy {
allow: ["read_file", "list_dir"]
.into_iter()
.map(String::from)
.collect(),
deny: ["bash", "write_file"]
.into_iter()
.map(String::from)
.collect(),
};
let json = serde_json::to_string(&policy).unwrap();
let deserialized: EnforcedToolPolicy = serde_json::from_str(&json).unwrap();
assert!(deserialized.allow.contains("read_file"));
assert!(deserialized.allow.contains("list_dir"));
assert!(deserialized.deny.contains("bash"));
assert!(deserialized.deny.contains("write_file"));
let empty_policy: EnforcedToolPolicy = serde_json::from_str("{}").unwrap();
assert!(empty_policy.allow.is_empty());
assert!(empty_policy.deny.is_empty());
}
#[test]
fn test_builtin_tool_config_serde() {
let config = BuiltinToolConfig {
policy: ToolPolicyLayer {
mode: Some(ToolMode::AllowList),
enable: ["read_file"].into_iter().map(String::from).collect(),
disable: ToolNameSet::new(),
},
enforced: EnforcedToolPolicy {
allow: ToolNameSet::new(),
deny: ["bash"].into_iter().map(String::from).collect(),
},
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: BuiltinToolConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.policy.mode, Some(ToolMode::AllowList));
assert!(deserialized.policy.enable.contains("read_file"));
assert!(deserialized.enforced.deny.contains("bash"));
let empty_config: BuiltinToolConfig = serde_json::from_str("{}").unwrap();
assert_eq!(empty_config.policy.mode, None);
assert!(empty_config.policy.enable.is_empty());
assert!(empty_config.enforced.allow.is_empty());
}
#[test]
fn test_policy_layer_builder_methods() {
let layer = ToolPolicyLayer::new()
.with_mode(ToolMode::DenyAll)
.enable_tool("read_file")
.enable_tool("write_file")
.disable_tool("bash");
assert_eq!(layer.mode, Some(ToolMode::DenyAll));
assert!(layer.enable.contains("read_file"));
assert!(layer.enable.contains("write_file"));
assert_eq!(layer.enable.len(), 2);
assert!(layer.disable.contains("bash"));
assert_eq!(layer.disable.len(), 1);
}
#[test]
fn test_tool_mode_default() {
let mode = ToolMode::default();
assert_eq!(mode, ToolMode::AllowAll);
}
#[test]
fn test_tool_policy_layer_default() {
let layer = ToolPolicyLayer::default();
assert_eq!(layer.mode, None);
assert!(layer.enable.is_empty());
assert!(layer.disable.is_empty());
}
#[test]
fn test_enforced_policy_default() {
let policy = EnforcedToolPolicy::default();
assert!(policy.allow.is_empty());
assert!(policy.deny.is_empty());
}
#[test]
fn test_builtin_tool_config_default() {
let config = BuiltinToolConfig::default();
assert_eq!(config.policy.mode, None);
assert!(config.policy.enable.is_empty());
assert!(config.enforced.allow.is_empty());
}
#[test]
fn test_merge_empty_layers() {
let resolved = merge_policy_layers(&[]);
assert_eq!(*resolved.mode(), ToolMode::AllowAll);
assert!(resolved.enabled().is_empty());
assert!(resolved.disabled().is_empty());
}
#[test]
fn test_merge_single_layer_enable() {
let layer = ToolPolicyLayer::new()
.enable_tool("read_file")
.enable_tool("write_file");
let resolved = merge_policy_layers(&[layer]);
assert_eq!(*resolved.mode(), ToolMode::AllowAll);
assert!(resolved.enabled().contains("read_file"));
assert!(resolved.enabled().contains("write_file"));
assert_eq!(resolved.enabled().len(), 2);
assert!(resolved.disabled().is_empty());
}
#[test]
fn test_merge_single_layer_disable() {
let layer = ToolPolicyLayer::new()
.disable_tool("bash")
.disable_tool("shell");
let resolved = merge_policy_layers(&[layer]);
assert_eq!(*resolved.mode(), ToolMode::AllowAll);
assert!(resolved.disabled().contains("bash"));
assert!(resolved.disabled().contains("shell"));
assert_eq!(resolved.disabled().len(), 2);
assert!(resolved.enabled().is_empty());
}
#[test]
fn test_merge_later_layer_wins() {
let layer1 = ToolPolicyLayer::new().enable_tool("bash");
let layer2 = ToolPolicyLayer::new().disable_tool("bash");
let resolved = merge_policy_layers(&[layer1, layer2]);
assert!(resolved.disabled().contains("bash"));
assert!(!resolved.enabled().contains("bash"));
let layer1 = ToolPolicyLayer::new().disable_tool("bash");
let layer2 = ToolPolicyLayer::new().enable_tool("bash");
let resolved = merge_policy_layers(&[layer1, layer2]);
assert!(resolved.enabled().contains("bash"));
assert!(!resolved.disabled().contains("bash"));
}
#[test]
fn test_merge_mode_override() {
let layer1 = ToolPolicyLayer::new().with_mode(ToolMode::AllowAll);
let layer2 = ToolPolicyLayer::new().with_mode(ToolMode::DenyAll);
let resolved = merge_policy_layers(&[layer1, layer2]);
assert_eq!(*resolved.mode(), ToolMode::DenyAll);
let layer1 = ToolPolicyLayer::new().with_mode(ToolMode::DenyAll);
let layer2 = ToolPolicyLayer::new().enable_tool("read_file"); let resolved = merge_policy_layers(&[layer1, layer2]);
assert_eq!(*resolved.mode(), ToolMode::DenyAll);
}
#[test]
fn test_enforced_deny_overrides_enable() {
let layer = ToolPolicyLayer::new()
.enable_tool("bash")
.enable_tool("read_file");
let resolved = merge_policy_layers(&[layer]);
assert!(resolved.enabled().contains("bash"));
let enforced = EnforcedToolPolicy {
allow: ToolNameSet::new(),
deny: ["bash"].into_iter().map(String::from).collect(),
};
let resolved = apply_enforced_policy(resolved, &enforced);
assert!(resolved.disabled().contains("bash"));
assert!(!resolved.enabled().contains("bash"));
assert!(resolved.enabled().contains("read_file"));
}
#[test]
fn test_enforced_allow_restricts_to_allowlist() {
let layer = ToolPolicyLayer::new()
.enable_tool("bash")
.enable_tool("read_file")
.enable_tool("write_file");
let resolved = merge_policy_layers(&[layer]);
assert_eq!(resolved.enabled().len(), 3);
let enforced = EnforcedToolPolicy {
allow: ["read_file"].into_iter().map(String::from).collect(),
deny: ToolNameSet::new(),
};
let resolved = apply_enforced_policy(resolved, &enforced);
assert!(resolved.enabled().contains("read_file"));
assert!(!resolved.enabled().contains("bash"));
assert!(!resolved.enabled().contains("write_file"));
assert_eq!(resolved.enabled().len(), 1);
assert_eq!(*resolved.mode(), ToolMode::AllowList);
}
#[test]
fn test_is_enabled_with_default_true() {
let resolved = merge_policy_layers(&[]);
assert!(resolved.is_enabled("any_tool", true));
let layer = ToolPolicyLayer::new().with_mode(ToolMode::DenyAll);
let resolved = merge_policy_layers(&[layer]);
assert!(!resolved.is_enabled("any_tool", true));
let layer = ToolPolicyLayer::new().with_mode(ToolMode::AllowList);
let resolved = merge_policy_layers(&[layer]);
assert!(!resolved.is_enabled("any_tool", true));
}
#[test]
fn test_is_enabled_with_default_false() {
let resolved = merge_policy_layers(&[]);
assert!(!resolved.is_enabled("any_tool", false));
let layer = ToolPolicyLayer::new().enable_tool("my_tool");
let resolved = merge_policy_layers(&[layer]);
assert!(resolved.is_enabled("my_tool", false));
let layer = ToolPolicyLayer::new().disable_tool("my_tool");
let resolved = merge_policy_layers(&[layer]);
assert!(!resolved.is_enabled("my_tool", true));
}
#[test]
fn test_builtin_tool_config_resolve() {
let config = BuiltinToolConfig {
policy: ToolPolicyLayer::new()
.with_mode(ToolMode::DenyAll)
.enable_tool("read_file")
.enable_tool("bash"),
enforced: EnforcedToolPolicy {
allow: ToolNameSet::new(),
deny: ["bash"].into_iter().map(String::from).collect(),
},
};
let resolved = config.resolve();
assert!(resolved.is_enabled("read_file", false));
assert!(!resolved.is_enabled("bash", false));
assert!(!resolved.is_enabled("write_file", true));
}
}