1pub use ai_agents_core::MAX_TOOL_TIMEOUT_MS;
2use ai_agents_core::{AgentError, PermissionOutcome, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ToolSecurityConfig {
9 #[serde(default)]
11 pub enabled: bool,
12 #[serde(default)]
14 pub fail_closed: bool,
15 #[serde(default = "default_tool_timeout")]
17 pub default_timeout_ms: u64,
18 #[serde(default)]
20 pub tools: HashMap<String, ToolPolicyConfig>,
21}
22
23impl Default for ToolSecurityConfig {
24 fn default() -> Self {
25 Self {
26 enabled: false,
27 fail_closed: false,
28 default_timeout_ms: default_tool_timeout(),
29 tools: HashMap::new(),
30 }
31 }
32}
33
34impl ToolSecurityConfig {
35 pub fn validate(&self) -> Result<()> {
37 let mut invalid_timeout_paths = Vec::new();
38 if self.default_timeout_ms > MAX_TOOL_TIMEOUT_MS {
39 invalid_timeout_paths.push("tool_security.default_timeout_ms".to_string());
40 }
41 invalid_timeout_paths.extend(
42 self.tools
43 .iter()
44 .filter(|(_, policy)| {
45 policy
46 .timeout_ms
47 .is_some_and(|timeout_ms| timeout_ms > MAX_TOOL_TIMEOUT_MS)
48 })
49 .map(|(tool_id, _)| format!("tool_security.tools.{tool_id}.timeout_ms")),
50 );
51 invalid_timeout_paths.sort();
52 if !invalid_timeout_paths.is_empty() {
53 return Err(AgentError::Config(format!(
54 "{} must be no greater than {MAX_TOOL_TIMEOUT_MS} milliseconds",
55 invalid_timeout_paths.join(", ")
56 )));
57 }
58
59 let mut invalid_paths: Vec<String> = self
60 .tools
61 .iter()
62 .filter(|(_, policy)| policy.max_results == Some(0))
63 .map(|(tool_id, _)| format!("tool_security.tools.{tool_id}.max_results"))
64 .collect();
65 invalid_paths.sort();
66 if invalid_paths.is_empty() {
67 return Ok(());
68 }
69 Err(AgentError::Config(format!(
70 "{} must be greater than 0",
71 invalid_paths.join(", ")
72 )))
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum NoWritePolicyBehavior {
80 Deny,
81 #[default]
82 DryRunOnly,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CommandRuleConfig {
88 #[serde(default)]
90 pub argv: Vec<String>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct CommandTemplateConfig {
96 pub name: String,
98 #[serde(default)]
100 pub argv: Vec<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ToolPolicyConfig {
106 #[serde(default = "default_true")]
108 pub enabled: bool,
109 #[serde(default, alias = "require_approval")]
111 pub require_confirmation: bool,
112 #[serde(default)]
114 pub allow_without_confirmation: bool,
115 #[serde(default)]
117 pub confirmation_message: Option<String>,
118 #[serde(default)]
120 pub rate_limit: Option<u32>,
121 #[serde(default)]
123 pub timeout_ms: Option<u64>,
124 #[serde(default)]
126 pub allowed_domains: Vec<String>,
127 #[serde(default)]
129 pub blocked_domains: Vec<String>,
130 #[serde(default)]
132 pub allowed_paths: Vec<String>,
133 #[serde(default)]
135 pub read_paths: Vec<String>,
136 #[serde(default)]
138 pub write_paths: Vec<String>,
139 #[serde(default)]
141 pub blocked_paths: Vec<String>,
142 #[serde(default)]
144 pub max_file_size_bytes: Option<u64>,
145 #[serde(default)]
147 pub max_output_chars: Option<usize>,
148 #[serde(default)]
150 pub max_results: Option<usize>,
151 #[serde(default)]
153 pub max_response_bytes: Option<usize>,
154 #[serde(default = "default_true")]
156 pub blocked_private_networks: bool,
157 #[serde(default)]
159 pub allowed_schemes: Vec<String>,
160 #[serde(default)]
162 pub allowed_ports: Vec<u16>,
163 #[serde(default)]
165 pub max_redirects: Option<usize>,
166 #[serde(default)]
168 pub max_changed_files: Option<usize>,
169 #[serde(default)]
171 pub max_changed_lines: Option<usize>,
172 #[serde(default)]
174 pub max_replacements: Option<usize>,
175 #[serde(default)]
177 pub require_read_before_write: bool,
178 #[serde(default)]
180 pub overwrite_existing: bool,
181 #[serde(default)]
183 pub create_parent_dirs: bool,
184 #[serde(default)]
186 pub no_write_policy: NoWritePolicyBehavior,
187 #[serde(default)]
189 pub allowed_commands: Vec<CommandRuleConfig>,
190 #[serde(default)]
192 pub command_templates: Vec<CommandTemplateConfig>,
193 #[serde(default)]
195 pub working_dirs: Vec<String>,
196 #[serde(default)]
198 pub env_passthrough: Vec<String>,
199 #[serde(default)]
201 pub redact_env: Vec<String>,
202 #[serde(default = "default_true")]
204 pub deny_shell: bool,
205 #[serde(default = "default_true")]
207 pub deny_interactive: bool,
208 #[serde(default)]
210 pub allow_command_escalation: bool,
211 #[serde(default)]
213 pub domains: DomainPolicyConfig,
214 #[serde(default)]
216 pub paths: PathPolicyConfig,
217 #[serde(default)]
219 pub commands: CommandPolicyConfig,
220 #[serde(default)]
222 pub operations: OperationPolicyConfig,
223 #[serde(default)]
225 pub config: HashMap<String, serde_json::Value>,
226}
227
228impl Default for ToolPolicyConfig {
229 fn default() -> Self {
230 Self {
231 enabled: true,
232 require_confirmation: false,
233 allow_without_confirmation: false,
234 confirmation_message: None,
235 rate_limit: None,
236 timeout_ms: None,
237 allowed_domains: Vec::new(),
238 blocked_domains: Vec::new(),
239 allowed_paths: Vec::new(),
240 read_paths: Vec::new(),
241 write_paths: Vec::new(),
242 blocked_paths: Vec::new(),
243 max_file_size_bytes: None,
244 max_output_chars: None,
245 max_results: None,
246 max_response_bytes: None,
247 blocked_private_networks: true,
248 allowed_schemes: Vec::new(),
249 allowed_ports: Vec::new(),
250 max_redirects: None,
251 max_changed_files: None,
252 max_changed_lines: None,
253 max_replacements: None,
254 require_read_before_write: false,
255 overwrite_existing: false,
256 create_parent_dirs: false,
257 no_write_policy: NoWritePolicyBehavior::default(),
258 allowed_commands: Vec::new(),
259 command_templates: Vec::new(),
260 working_dirs: Vec::new(),
261 env_passthrough: Vec::new(),
262 redact_env: Vec::new(),
263 deny_shell: true,
264 deny_interactive: true,
265 allow_command_escalation: false,
266 domains: DomainPolicyConfig::default(),
267 paths: PathPolicyConfig::default(),
268 commands: CommandPolicyConfig::default(),
269 operations: OperationPolicyConfig::default(),
270 config: HashMap::new(),
271 }
272 }
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277pub struct DomainPolicyConfig {
278 #[serde(default)]
279 pub allow: Vec<String>,
280 #[serde(default)]
281 pub deny: Vec<String>,
282 #[serde(default)]
283 pub requires_approval: Vec<String>,
284 #[serde(default)]
285 pub unavailable: Vec<String>,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct PathPolicyConfig {
291 #[serde(default)]
292 pub allow: Vec<String>,
293 #[serde(default)]
294 pub deny: Vec<String>,
295 #[serde(default)]
296 pub requires_approval: Vec<String>,
297 #[serde(default)]
298 pub unavailable: Vec<String>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct CommandPolicyConfig {
304 #[serde(default)]
305 pub allow: Vec<String>,
306 #[serde(default)]
307 pub deny: Vec<String>,
308 #[serde(default)]
309 pub requires_approval: Vec<String>,
310 #[serde(default)]
311 pub unavailable: Vec<String>,
312 #[serde(default)]
313 pub allowed_commands: Vec<CommandRuleConfig>,
314 #[serde(default)]
315 pub templates: Vec<CommandTemplateConfig>,
316 #[serde(default)]
317 pub working_dirs: Vec<String>,
318 #[serde(default)]
319 pub env_passthrough: Vec<String>,
320 #[serde(default = "default_true")]
321 pub deny_shell: bool,
322 #[serde(default = "default_true")]
323 pub deny_interactive: bool,
324 #[serde(default)]
325 pub allow_escalation: bool,
326}
327
328impl Default for CommandPolicyConfig {
329 fn default() -> Self {
330 Self {
331 allow: Vec::new(),
332 deny: Vec::new(),
333 requires_approval: Vec::new(),
334 unavailable: Vec::new(),
335 allowed_commands: Vec::new(),
336 templates: Vec::new(),
337 working_dirs: Vec::new(),
338 env_passthrough: Vec::new(),
339 deny_shell: true,
340 deny_interactive: true,
341 allow_escalation: false,
342 }
343 }
344}
345
346#[derive(Debug, Clone, Default, Serialize, Deserialize)]
348pub struct OperationPolicyConfig {
349 #[serde(default)]
350 pub allow: Vec<String>,
351 #[serde(default)]
352 pub deny: Vec<String>,
353 #[serde(default)]
354 pub requires_approval: Vec<String>,
355 #[serde(default)]
356 pub unavailable: Vec<String>,
357}
358
359#[derive(Debug, Clone)]
361pub enum SecurityCheckResult {
362 Allow,
363 Block { reason: String },
364 Warn { message: String },
365 RequireConfirmation { message: String },
366 Unavailable { reason: String },
367}
368
369impl SecurityCheckResult {
370 pub fn is_allowed(&self) -> bool {
372 matches!(
373 self,
374 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
375 )
376 }
377
378 pub fn is_blocked(&self) -> bool {
380 matches!(
381 self,
382 SecurityCheckResult::Block { .. } | SecurityCheckResult::Unavailable { .. }
383 )
384 }
385
386 pub fn outcome(&self) -> PermissionOutcome {
388 match self {
389 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. } => {
390 PermissionOutcome::Allow
391 }
392 SecurityCheckResult::Block { .. } => PermissionOutcome::Deny,
393 SecurityCheckResult::RequireConfirmation { .. } => PermissionOutcome::RequiresApproval,
394 SecurityCheckResult::Unavailable { .. } => PermissionOutcome::Unavailable,
395 }
396 }
397
398 pub fn reason(&self) -> Option<&str> {
400 match self {
401 SecurityCheckResult::Allow => None,
402 SecurityCheckResult::Block { reason } => Some(reason),
403 SecurityCheckResult::Warn { message } => Some(message),
404 SecurityCheckResult::RequireConfirmation { message } => Some(message),
405 SecurityCheckResult::Unavailable { reason } => Some(reason),
406 }
407 }
408
409 pub fn requires_approval(&self) -> bool {
411 matches!(self, SecurityCheckResult::RequireConfirmation { .. })
412 }
413
414 pub fn is_unavailable(&self) -> bool {
416 matches!(self, SecurityCheckResult::Unavailable { .. })
417 }
418}
419
420fn default_tool_timeout() -> u64 {
421 30000
422}
423
424fn default_true() -> bool {
425 true
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn test_default_config() {
434 let config = ToolSecurityConfig::default();
435 assert!(!config.enabled);
436 assert_eq!(config.default_timeout_ms, 30000);
437 assert!(config.tools.is_empty());
438 }
439
440 #[test]
441 fn test_yaml_parsing() {
442 let yaml = r#"
443enabled: true
444default_timeout_ms: 10000
445tools:
446 http:
447 rate_limit: 10
448 blocked_domains:
449 - evil.com
450 allowed_domains:
451 - api.example.com
452 file_write:
453 require_confirmation: true
454 confirmation_message: "Are you sure you want to write this file?"
455 allowed_paths:
456 - /tmp/
457"#;
458 let config: ToolSecurityConfig = serde_yaml::from_str(yaml).unwrap();
459 assert!(config.enabled);
460 assert_eq!(config.default_timeout_ms, 10000);
461 assert!(config.tools.contains_key("http"));
462 assert!(config.tools.contains_key("file_write"));
463
464 let http = config.tools.get("http").unwrap();
465 assert_eq!(http.rate_limit, Some(10));
466 assert_eq!(http.blocked_domains, vec!["evil.com"]);
467
468 let file_write = config.tools.get("file_write").unwrap();
469 assert!(file_write.require_confirmation);
470 }
471
472 #[test]
473 fn test_security_check_result() {
474 let allow = SecurityCheckResult::Allow;
475 assert!(allow.is_allowed());
476 assert!(!allow.is_blocked());
477
478 let block = SecurityCheckResult::Block {
479 reason: "test".into(),
480 };
481 assert!(!block.is_allowed());
482 assert!(block.is_blocked());
483
484 let warn = SecurityCheckResult::Warn {
485 message: "warning".into(),
486 };
487 assert!(warn.is_allowed());
488 assert!(!warn.is_blocked());
489 }
490
491 #[test]
492 fn test_tool_policy_defaults() {
493 let policy = ToolPolicyConfig::default();
494 assert!(policy.enabled);
495 assert!(!policy.require_confirmation);
496 assert!(policy.rate_limit.is_none());
497 }
498
499 #[test]
500 fn tool_timeouts_accept_the_documented_range() {
501 let mut config = ToolSecurityConfig {
502 default_timeout_ms: MAX_TOOL_TIMEOUT_MS,
503 ..Default::default()
504 };
505 config.tools.insert(
506 "slow".to_string(),
507 ToolPolicyConfig {
508 timeout_ms: Some(MAX_TOOL_TIMEOUT_MS),
509 ..Default::default()
510 },
511 );
512
513 assert!(config.validate().is_ok());
514 }
515
516 #[test]
517 fn default_tool_timeout_rejects_unrepresentable_values() {
518 for timeout_ms in [MAX_TOOL_TIMEOUT_MS + 1, u64::MAX] {
519 let config = ToolSecurityConfig {
520 default_timeout_ms: timeout_ms,
521 ..Default::default()
522 };
523 let error = config.validate().unwrap_err();
524 assert!(error.to_string().contains(&format!(
525 "tool_security.default_timeout_ms must be no greater than {MAX_TOOL_TIMEOUT_MS} milliseconds"
526 )));
527 }
528 }
529
530 #[test]
531 fn per_tool_timeout_rejects_unrepresentable_values_with_full_paths() {
532 for timeout_ms in [MAX_TOOL_TIMEOUT_MS + 1, u64::MAX] {
533 let mut config = ToolSecurityConfig::default();
534 config.tools.insert(
535 "slow".to_string(),
536 ToolPolicyConfig {
537 timeout_ms: Some(timeout_ms),
538 ..Default::default()
539 },
540 );
541 let error = config.validate().unwrap_err();
542 assert!(error.to_string().contains(&format!(
543 "tool_security.tools.slow.timeout_ms must be no greater than {MAX_TOOL_TIMEOUT_MS} milliseconds"
544 )));
545 }
546 }
547
548 #[test]
549 fn max_results_must_be_positive() {
550 let mut config = ToolSecurityConfig::default();
551 config.tools.insert(
552 "web_search".to_string(),
553 ToolPolicyConfig {
554 max_results: Some(0),
555 ..Default::default()
556 },
557 );
558 let error = config.validate().unwrap_err();
559 assert!(
560 error
561 .to_string()
562 .contains("tool_security.tools.web_search.max_results must be greater than 0")
563 );
564
565 config.tools.get_mut("web_search").unwrap().max_results = Some(1);
566 assert!(config.validate().is_ok());
567 }
568
569 #[test]
570 fn zero_redirect_limit_remains_valid() {
571 let mut config = ToolSecurityConfig::default();
572 config.tools.insert(
573 "web_fetch".to_string(),
574 ToolPolicyConfig {
575 max_redirects: Some(0),
576 ..Default::default()
577 },
578 );
579 assert!(config.validate().is_ok());
580 }
581}