pub use ai_agents_core::MAX_TOOL_TIMEOUT_MS;
use ai_agents_core::{AgentError, PermissionOutcome, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSecurityConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub fail_closed: bool,
#[serde(default = "default_tool_timeout")]
pub default_timeout_ms: u64,
#[serde(default)]
pub tools: HashMap<String, ToolPolicyConfig>,
}
impl Default for ToolSecurityConfig {
fn default() -> Self {
Self {
enabled: false,
fail_closed: false,
default_timeout_ms: default_tool_timeout(),
tools: HashMap::new(),
}
}
}
impl ToolSecurityConfig {
pub fn validate(&self) -> Result<()> {
let mut invalid_timeout_paths = Vec::new();
if self.default_timeout_ms > MAX_TOOL_TIMEOUT_MS {
invalid_timeout_paths.push("tool_security.default_timeout_ms".to_string());
}
invalid_timeout_paths.extend(
self.tools
.iter()
.filter(|(_, policy)| {
policy
.timeout_ms
.is_some_and(|timeout_ms| timeout_ms > MAX_TOOL_TIMEOUT_MS)
})
.map(|(tool_id, _)| format!("tool_security.tools.{tool_id}.timeout_ms")),
);
invalid_timeout_paths.sort();
if !invalid_timeout_paths.is_empty() {
return Err(AgentError::Config(format!(
"{} must be no greater than {MAX_TOOL_TIMEOUT_MS} milliseconds",
invalid_timeout_paths.join(", ")
)));
}
let mut invalid_paths: Vec<String> = self
.tools
.iter()
.filter(|(_, policy)| policy.max_results == Some(0))
.map(|(tool_id, _)| format!("tool_security.tools.{tool_id}.max_results"))
.collect();
invalid_paths.sort();
if invalid_paths.is_empty() {
return Ok(());
}
Err(AgentError::Config(format!(
"{} must be greater than 0",
invalid_paths.join(", ")
)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NoWritePolicyBehavior {
Deny,
#[default]
DryRunOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandRuleConfig {
#[serde(default)]
pub argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandTemplateConfig {
pub name: String,
#[serde(default)]
pub argv: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolPolicyConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, alias = "require_approval")]
pub require_confirmation: bool,
#[serde(default)]
pub allow_without_confirmation: bool,
#[serde(default)]
pub confirmation_message: Option<String>,
#[serde(default)]
pub rate_limit: Option<u32>,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub blocked_domains: Vec<String>,
#[serde(default)]
pub allowed_paths: Vec<String>,
#[serde(default)]
pub read_paths: Vec<String>,
#[serde(default)]
pub write_paths: Vec<String>,
#[serde(default)]
pub blocked_paths: Vec<String>,
#[serde(default)]
pub max_file_size_bytes: Option<u64>,
#[serde(default)]
pub max_output_chars: Option<usize>,
#[serde(default)]
pub max_results: Option<usize>,
#[serde(default)]
pub max_response_bytes: Option<usize>,
#[serde(default = "default_true")]
pub blocked_private_networks: bool,
#[serde(default)]
pub allowed_schemes: Vec<String>,
#[serde(default)]
pub allowed_ports: Vec<u16>,
#[serde(default)]
pub max_redirects: Option<usize>,
#[serde(default)]
pub max_changed_files: Option<usize>,
#[serde(default)]
pub max_changed_lines: Option<usize>,
#[serde(default)]
pub max_replacements: Option<usize>,
#[serde(default)]
pub require_read_before_write: bool,
#[serde(default)]
pub overwrite_existing: bool,
#[serde(default)]
pub create_parent_dirs: bool,
#[serde(default)]
pub no_write_policy: NoWritePolicyBehavior,
#[serde(default)]
pub allowed_commands: Vec<CommandRuleConfig>,
#[serde(default)]
pub command_templates: Vec<CommandTemplateConfig>,
#[serde(default)]
pub working_dirs: Vec<String>,
#[serde(default)]
pub env_passthrough: Vec<String>,
#[serde(default)]
pub redact_env: Vec<String>,
#[serde(default = "default_true")]
pub deny_shell: bool,
#[serde(default = "default_true")]
pub deny_interactive: bool,
#[serde(default)]
pub allow_command_escalation: bool,
#[serde(default)]
pub domains: DomainPolicyConfig,
#[serde(default)]
pub paths: PathPolicyConfig,
#[serde(default)]
pub commands: CommandPolicyConfig,
#[serde(default)]
pub operations: OperationPolicyConfig,
#[serde(default)]
pub config: HashMap<String, serde_json::Value>,
}
impl Default for ToolPolicyConfig {
fn default() -> Self {
Self {
enabled: true,
require_confirmation: false,
allow_without_confirmation: false,
confirmation_message: None,
rate_limit: None,
timeout_ms: None,
allowed_domains: Vec::new(),
blocked_domains: Vec::new(),
allowed_paths: Vec::new(),
read_paths: Vec::new(),
write_paths: Vec::new(),
blocked_paths: Vec::new(),
max_file_size_bytes: None,
max_output_chars: None,
max_results: None,
max_response_bytes: None,
blocked_private_networks: true,
allowed_schemes: Vec::new(),
allowed_ports: Vec::new(),
max_redirects: None,
max_changed_files: None,
max_changed_lines: None,
max_replacements: None,
require_read_before_write: false,
overwrite_existing: false,
create_parent_dirs: false,
no_write_policy: NoWritePolicyBehavior::default(),
allowed_commands: Vec::new(),
command_templates: Vec::new(),
working_dirs: Vec::new(),
env_passthrough: Vec::new(),
redact_env: Vec::new(),
deny_shell: true,
deny_interactive: true,
allow_command_escalation: false,
domains: DomainPolicyConfig::default(),
paths: PathPolicyConfig::default(),
commands: CommandPolicyConfig::default(),
operations: OperationPolicyConfig::default(),
config: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DomainPolicyConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub requires_approval: Vec<String>,
#[serde(default)]
pub unavailable: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PathPolicyConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub requires_approval: Vec<String>,
#[serde(default)]
pub unavailable: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandPolicyConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub requires_approval: Vec<String>,
#[serde(default)]
pub unavailable: Vec<String>,
#[serde(default)]
pub allowed_commands: Vec<CommandRuleConfig>,
#[serde(default)]
pub templates: Vec<CommandTemplateConfig>,
#[serde(default)]
pub working_dirs: Vec<String>,
#[serde(default)]
pub env_passthrough: Vec<String>,
#[serde(default = "default_true")]
pub deny_shell: bool,
#[serde(default = "default_true")]
pub deny_interactive: bool,
#[serde(default)]
pub allow_escalation: bool,
}
impl Default for CommandPolicyConfig {
fn default() -> Self {
Self {
allow: Vec::new(),
deny: Vec::new(),
requires_approval: Vec::new(),
unavailable: Vec::new(),
allowed_commands: Vec::new(),
templates: Vec::new(),
working_dirs: Vec::new(),
env_passthrough: Vec::new(),
deny_shell: true,
deny_interactive: true,
allow_escalation: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OperationPolicyConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub requires_approval: Vec<String>,
#[serde(default)]
pub unavailable: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum SecurityCheckResult {
Allow,
Block { reason: String },
Warn { message: String },
RequireConfirmation { message: String },
Unavailable { reason: String },
}
impl SecurityCheckResult {
pub fn is_allowed(&self) -> bool {
matches!(
self,
SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
)
}
pub fn is_blocked(&self) -> bool {
matches!(
self,
SecurityCheckResult::Block { .. } | SecurityCheckResult::Unavailable { .. }
)
}
pub fn outcome(&self) -> PermissionOutcome {
match self {
SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. } => {
PermissionOutcome::Allow
}
SecurityCheckResult::Block { .. } => PermissionOutcome::Deny,
SecurityCheckResult::RequireConfirmation { .. } => PermissionOutcome::RequiresApproval,
SecurityCheckResult::Unavailable { .. } => PermissionOutcome::Unavailable,
}
}
pub fn reason(&self) -> Option<&str> {
match self {
SecurityCheckResult::Allow => None,
SecurityCheckResult::Block { reason } => Some(reason),
SecurityCheckResult::Warn { message } => Some(message),
SecurityCheckResult::RequireConfirmation { message } => Some(message),
SecurityCheckResult::Unavailable { reason } => Some(reason),
}
}
pub fn requires_approval(&self) -> bool {
matches!(self, SecurityCheckResult::RequireConfirmation { .. })
}
pub fn is_unavailable(&self) -> bool {
matches!(self, SecurityCheckResult::Unavailable { .. })
}
}
fn default_tool_timeout() -> u64 {
30000
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ToolSecurityConfig::default();
assert!(!config.enabled);
assert_eq!(config.default_timeout_ms, 30000);
assert!(config.tools.is_empty());
}
#[test]
fn test_yaml_parsing() {
let yaml = r#"
enabled: true
default_timeout_ms: 10000
tools:
http:
rate_limit: 10
blocked_domains:
- evil.com
allowed_domains:
- api.example.com
file_write:
require_confirmation: true
confirmation_message: "Are you sure you want to write this file?"
allowed_paths:
- /tmp/
"#;
let config: ToolSecurityConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.enabled);
assert_eq!(config.default_timeout_ms, 10000);
assert!(config.tools.contains_key("http"));
assert!(config.tools.contains_key("file_write"));
let http = config.tools.get("http").unwrap();
assert_eq!(http.rate_limit, Some(10));
assert_eq!(http.blocked_domains, vec!["evil.com"]);
let file_write = config.tools.get("file_write").unwrap();
assert!(file_write.require_confirmation);
}
#[test]
fn test_security_check_result() {
let allow = SecurityCheckResult::Allow;
assert!(allow.is_allowed());
assert!(!allow.is_blocked());
let block = SecurityCheckResult::Block {
reason: "test".into(),
};
assert!(!block.is_allowed());
assert!(block.is_blocked());
let warn = SecurityCheckResult::Warn {
message: "warning".into(),
};
assert!(warn.is_allowed());
assert!(!warn.is_blocked());
}
#[test]
fn test_tool_policy_defaults() {
let policy = ToolPolicyConfig::default();
assert!(policy.enabled);
assert!(!policy.require_confirmation);
assert!(policy.rate_limit.is_none());
}
#[test]
fn tool_timeouts_accept_the_documented_range() {
let mut config = ToolSecurityConfig {
default_timeout_ms: MAX_TOOL_TIMEOUT_MS,
..Default::default()
};
config.tools.insert(
"slow".to_string(),
ToolPolicyConfig {
timeout_ms: Some(MAX_TOOL_TIMEOUT_MS),
..Default::default()
},
);
assert!(config.validate().is_ok());
}
#[test]
fn default_tool_timeout_rejects_unrepresentable_values() {
for timeout_ms in [MAX_TOOL_TIMEOUT_MS + 1, u64::MAX] {
let config = ToolSecurityConfig {
default_timeout_ms: timeout_ms,
..Default::default()
};
let error = config.validate().unwrap_err();
assert!(error.to_string().contains(&format!(
"tool_security.default_timeout_ms must be no greater than {MAX_TOOL_TIMEOUT_MS} milliseconds"
)));
}
}
#[test]
fn per_tool_timeout_rejects_unrepresentable_values_with_full_paths() {
for timeout_ms in [MAX_TOOL_TIMEOUT_MS + 1, u64::MAX] {
let mut config = ToolSecurityConfig::default();
config.tools.insert(
"slow".to_string(),
ToolPolicyConfig {
timeout_ms: Some(timeout_ms),
..Default::default()
},
);
let error = config.validate().unwrap_err();
assert!(error.to_string().contains(&format!(
"tool_security.tools.slow.timeout_ms must be no greater than {MAX_TOOL_TIMEOUT_MS} milliseconds"
)));
}
}
#[test]
fn max_results_must_be_positive() {
let mut config = ToolSecurityConfig::default();
config.tools.insert(
"web_search".to_string(),
ToolPolicyConfig {
max_results: Some(0),
..Default::default()
},
);
let error = config.validate().unwrap_err();
assert!(
error
.to_string()
.contains("tool_security.tools.web_search.max_results must be greater than 0")
);
config.tools.get_mut("web_search").unwrap().max_results = Some(1);
assert!(config.validate().is_ok());
}
#[test]
fn zero_redirect_limit_remains_valid() {
let mut config = ToolSecurityConfig::default();
config.tools.insert(
"web_fetch".to_string(),
ToolPolicyConfig {
max_redirects: Some(0),
..Default::default()
},
);
assert!(config.validate().is_ok());
}
}