1use ai_agents_core::{AgentError, PermissionOutcome, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ToolSecurityConfig {
8 #[serde(default)]
10 pub enabled: bool,
11 #[serde(default)]
13 pub fail_closed: bool,
14 #[serde(default = "default_tool_timeout")]
16 pub default_timeout_ms: u64,
17 #[serde(default)]
19 pub tools: HashMap<String, ToolPolicyConfig>,
20}
21
22impl Default for ToolSecurityConfig {
23 fn default() -> Self {
24 Self {
25 enabled: false,
26 fail_closed: false,
27 default_timeout_ms: default_tool_timeout(),
28 tools: HashMap::new(),
29 }
30 }
31}
32
33impl ToolSecurityConfig {
34 pub fn validate(&self) -> Result<()> {
36 let mut invalid_paths: Vec<String> = self
37 .tools
38 .iter()
39 .filter(|(_, policy)| policy.max_results == Some(0))
40 .map(|(tool_id, _)| format!("tool_security.tools.{tool_id}.max_results"))
41 .collect();
42 invalid_paths.sort();
43 if invalid_paths.is_empty() {
44 return Ok(());
45 }
46 Err(AgentError::Config(format!(
47 "{} must be greater than 0",
48 invalid_paths.join(", ")
49 )))
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(rename_all = "snake_case")]
56pub enum NoWritePolicyBehavior {
57 Deny,
58 #[default]
59 DryRunOnly,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct CommandRuleConfig {
65 #[serde(default)]
67 pub argv: Vec<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct CommandTemplateConfig {
73 pub name: String,
75 #[serde(default)]
77 pub argv: Vec<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ToolPolicyConfig {
83 #[serde(default = "default_true")]
85 pub enabled: bool,
86 #[serde(default, alias = "require_approval")]
88 pub require_confirmation: bool,
89 #[serde(default)]
91 pub allow_without_confirmation: bool,
92 #[serde(default)]
94 pub confirmation_message: Option<String>,
95 #[serde(default)]
97 pub rate_limit: Option<u32>,
98 #[serde(default)]
100 pub timeout_ms: Option<u64>,
101 #[serde(default)]
103 pub allowed_domains: Vec<String>,
104 #[serde(default)]
106 pub blocked_domains: Vec<String>,
107 #[serde(default)]
109 pub allowed_paths: Vec<String>,
110 #[serde(default)]
112 pub read_paths: Vec<String>,
113 #[serde(default)]
115 pub write_paths: Vec<String>,
116 #[serde(default)]
118 pub blocked_paths: Vec<String>,
119 #[serde(default)]
121 pub max_file_size_bytes: Option<u64>,
122 #[serde(default)]
124 pub max_output_chars: Option<usize>,
125 #[serde(default)]
127 pub max_results: Option<usize>,
128 #[serde(default)]
130 pub max_response_bytes: Option<usize>,
131 #[serde(default = "default_true")]
133 pub blocked_private_networks: bool,
134 #[serde(default)]
136 pub allowed_schemes: Vec<String>,
137 #[serde(default)]
139 pub allowed_ports: Vec<u16>,
140 #[serde(default)]
142 pub max_redirects: Option<usize>,
143 #[serde(default)]
145 pub max_changed_files: Option<usize>,
146 #[serde(default)]
148 pub max_changed_lines: Option<usize>,
149 #[serde(default)]
151 pub max_replacements: Option<usize>,
152 #[serde(default)]
154 pub require_read_before_write: bool,
155 #[serde(default)]
157 pub overwrite_existing: bool,
158 #[serde(default)]
160 pub create_parent_dirs: bool,
161 #[serde(default)]
163 pub no_write_policy: NoWritePolicyBehavior,
164 #[serde(default)]
166 pub allowed_commands: Vec<CommandRuleConfig>,
167 #[serde(default)]
169 pub command_templates: Vec<CommandTemplateConfig>,
170 #[serde(default)]
172 pub working_dirs: Vec<String>,
173 #[serde(default)]
175 pub env_passthrough: Vec<String>,
176 #[serde(default)]
178 pub redact_env: Vec<String>,
179 #[serde(default = "default_true")]
181 pub deny_shell: bool,
182 #[serde(default = "default_true")]
184 pub deny_interactive: bool,
185 #[serde(default)]
187 pub allow_command_escalation: bool,
188 #[serde(default)]
190 pub domains: DomainPolicyConfig,
191 #[serde(default)]
193 pub paths: PathPolicyConfig,
194 #[serde(default)]
196 pub commands: CommandPolicyConfig,
197 #[serde(default)]
199 pub operations: OperationPolicyConfig,
200 #[serde(default)]
202 pub config: HashMap<String, serde_json::Value>,
203}
204
205impl Default for ToolPolicyConfig {
206 fn default() -> Self {
207 Self {
208 enabled: true,
209 require_confirmation: false,
210 allow_without_confirmation: false,
211 confirmation_message: None,
212 rate_limit: None,
213 timeout_ms: None,
214 allowed_domains: Vec::new(),
215 blocked_domains: Vec::new(),
216 allowed_paths: Vec::new(),
217 read_paths: Vec::new(),
218 write_paths: Vec::new(),
219 blocked_paths: Vec::new(),
220 max_file_size_bytes: None,
221 max_output_chars: None,
222 max_results: None,
223 max_response_bytes: None,
224 blocked_private_networks: true,
225 allowed_schemes: Vec::new(),
226 allowed_ports: Vec::new(),
227 max_redirects: None,
228 max_changed_files: None,
229 max_changed_lines: None,
230 max_replacements: None,
231 require_read_before_write: false,
232 overwrite_existing: false,
233 create_parent_dirs: false,
234 no_write_policy: NoWritePolicyBehavior::default(),
235 allowed_commands: Vec::new(),
236 command_templates: Vec::new(),
237 working_dirs: Vec::new(),
238 env_passthrough: Vec::new(),
239 redact_env: Vec::new(),
240 deny_shell: true,
241 deny_interactive: true,
242 allow_command_escalation: false,
243 domains: DomainPolicyConfig::default(),
244 paths: PathPolicyConfig::default(),
245 commands: CommandPolicyConfig::default(),
246 operations: OperationPolicyConfig::default(),
247 config: HashMap::new(),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct DomainPolicyConfig {
255 #[serde(default)]
256 pub allow: Vec<String>,
257 #[serde(default)]
258 pub deny: Vec<String>,
259 #[serde(default)]
260 pub requires_approval: Vec<String>,
261 #[serde(default)]
262 pub unavailable: Vec<String>,
263}
264
265#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267pub struct PathPolicyConfig {
268 #[serde(default)]
269 pub allow: Vec<String>,
270 #[serde(default)]
271 pub deny: Vec<String>,
272 #[serde(default)]
273 pub requires_approval: Vec<String>,
274 #[serde(default)]
275 pub unavailable: Vec<String>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct CommandPolicyConfig {
281 #[serde(default)]
282 pub allow: Vec<String>,
283 #[serde(default)]
284 pub deny: Vec<String>,
285 #[serde(default)]
286 pub requires_approval: Vec<String>,
287 #[serde(default)]
288 pub unavailable: Vec<String>,
289 #[serde(default)]
290 pub allowed_commands: Vec<CommandRuleConfig>,
291 #[serde(default)]
292 pub templates: Vec<CommandTemplateConfig>,
293 #[serde(default)]
294 pub working_dirs: Vec<String>,
295 #[serde(default)]
296 pub env_passthrough: Vec<String>,
297 #[serde(default = "default_true")]
298 pub deny_shell: bool,
299 #[serde(default = "default_true")]
300 pub deny_interactive: bool,
301 #[serde(default)]
302 pub allow_escalation: bool,
303}
304
305impl Default for CommandPolicyConfig {
306 fn default() -> Self {
307 Self {
308 allow: Vec::new(),
309 deny: Vec::new(),
310 requires_approval: Vec::new(),
311 unavailable: Vec::new(),
312 allowed_commands: Vec::new(),
313 templates: Vec::new(),
314 working_dirs: Vec::new(),
315 env_passthrough: Vec::new(),
316 deny_shell: true,
317 deny_interactive: true,
318 allow_escalation: false,
319 }
320 }
321}
322
323#[derive(Debug, Clone, Default, Serialize, Deserialize)]
325pub struct OperationPolicyConfig {
326 #[serde(default)]
327 pub allow: Vec<String>,
328 #[serde(default)]
329 pub deny: Vec<String>,
330 #[serde(default)]
331 pub requires_approval: Vec<String>,
332 #[serde(default)]
333 pub unavailable: Vec<String>,
334}
335
336#[derive(Debug, Clone)]
338pub enum SecurityCheckResult {
339 Allow,
340 Block { reason: String },
341 Warn { message: String },
342 RequireConfirmation { message: String },
343 Unavailable { reason: String },
344}
345
346impl SecurityCheckResult {
347 pub fn is_allowed(&self) -> bool {
349 matches!(
350 self,
351 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
352 )
353 }
354
355 pub fn is_blocked(&self) -> bool {
357 matches!(
358 self,
359 SecurityCheckResult::Block { .. } | SecurityCheckResult::Unavailable { .. }
360 )
361 }
362
363 pub fn outcome(&self) -> PermissionOutcome {
365 match self {
366 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. } => {
367 PermissionOutcome::Allow
368 }
369 SecurityCheckResult::Block { .. } => PermissionOutcome::Deny,
370 SecurityCheckResult::RequireConfirmation { .. } => PermissionOutcome::RequiresApproval,
371 SecurityCheckResult::Unavailable { .. } => PermissionOutcome::Unavailable,
372 }
373 }
374
375 pub fn reason(&self) -> Option<&str> {
377 match self {
378 SecurityCheckResult::Allow => None,
379 SecurityCheckResult::Block { reason } => Some(reason),
380 SecurityCheckResult::Warn { message } => Some(message),
381 SecurityCheckResult::RequireConfirmation { message } => Some(message),
382 SecurityCheckResult::Unavailable { reason } => Some(reason),
383 }
384 }
385
386 pub fn requires_approval(&self) -> bool {
388 matches!(self, SecurityCheckResult::RequireConfirmation { .. })
389 }
390
391 pub fn is_unavailable(&self) -> bool {
393 matches!(self, SecurityCheckResult::Unavailable { .. })
394 }
395}
396
397fn default_tool_timeout() -> u64 {
398 30000
399}
400
401fn default_true() -> bool {
402 true
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn test_default_config() {
411 let config = ToolSecurityConfig::default();
412 assert!(!config.enabled);
413 assert_eq!(config.default_timeout_ms, 30000);
414 assert!(config.tools.is_empty());
415 }
416
417 #[test]
418 fn test_yaml_parsing() {
419 let yaml = r#"
420enabled: true
421default_timeout_ms: 10000
422tools:
423 http:
424 rate_limit: 10
425 blocked_domains:
426 - evil.com
427 allowed_domains:
428 - api.example.com
429 file_write:
430 require_confirmation: true
431 confirmation_message: "Are you sure you want to write this file?"
432 allowed_paths:
433 - /tmp/
434"#;
435 let config: ToolSecurityConfig = serde_yaml::from_str(yaml).unwrap();
436 assert!(config.enabled);
437 assert_eq!(config.default_timeout_ms, 10000);
438 assert!(config.tools.contains_key("http"));
439 assert!(config.tools.contains_key("file_write"));
440
441 let http = config.tools.get("http").unwrap();
442 assert_eq!(http.rate_limit, Some(10));
443 assert_eq!(http.blocked_domains, vec!["evil.com"]);
444
445 let file_write = config.tools.get("file_write").unwrap();
446 assert!(file_write.require_confirmation);
447 }
448
449 #[test]
450 fn test_security_check_result() {
451 let allow = SecurityCheckResult::Allow;
452 assert!(allow.is_allowed());
453 assert!(!allow.is_blocked());
454
455 let block = SecurityCheckResult::Block {
456 reason: "test".into(),
457 };
458 assert!(!block.is_allowed());
459 assert!(block.is_blocked());
460
461 let warn = SecurityCheckResult::Warn {
462 message: "warning".into(),
463 };
464 assert!(warn.is_allowed());
465 assert!(!warn.is_blocked());
466 }
467
468 #[test]
469 fn test_tool_policy_defaults() {
470 let policy = ToolPolicyConfig::default();
471 assert!(policy.enabled);
472 assert!(!policy.require_confirmation);
473 assert!(policy.rate_limit.is_none());
474 }
475
476 #[test]
477 fn max_results_must_be_positive() {
478 let mut config = ToolSecurityConfig::default();
479 config.tools.insert(
480 "web_search".to_string(),
481 ToolPolicyConfig {
482 max_results: Some(0),
483 ..Default::default()
484 },
485 );
486 let error = config.validate().unwrap_err();
487 assert!(
488 error
489 .to_string()
490 .contains("tool_security.tools.web_search.max_results must be greater than 0")
491 );
492
493 config.tools.get_mut("web_search").unwrap().max_results = Some(1);
494 assert!(config.validate().is_ok());
495 }
496
497 #[test]
498 fn zero_redirect_limit_remains_valid() {
499 let mut config = ToolSecurityConfig::default();
500 config.tools.insert(
501 "web_fetch".to_string(),
502 ToolPolicyConfig {
503 max_redirects: Some(0),
504 ..Default::default()
505 },
506 );
507 assert!(config.validate().is_ok());
508 }
509}