Skip to main content

ai_agents_tools/security/
config.rs

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/// Runtime security configuration for tool execution.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ToolSecurityConfig {
9    /// Enables runtime tool security checks.
10    #[serde(default)]
11    pub enabled: bool,
12    /// Blocks tools without explicit policy when security is enabled.
13    #[serde(default)]
14    pub fail_closed: bool,
15    /// Default timeout for tool execution in milliseconds, up to [`MAX_TOOL_TIMEOUT_MS`].
16    #[serde(default = "default_tool_timeout")]
17    pub default_timeout_ms: u64,
18    /// Per-tool policies keyed by canonical tool ID.
19    #[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    /// Validates policy values that have a framework-wide semantic domain.
36    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/// Behavior when a mutation tool has no explicit write policy.
77#[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/// Exact argv command allowed by command policy.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CommandRuleConfig {
88    /// Full argv vector, including executable name.
89    #[serde(default)]
90    pub argv: Vec<String>,
91}
92
93/// Argv command template with literal and wildcard variable segments.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct CommandTemplateConfig {
96    /// Template name used in evidence and diagnostics.
97    pub name: String,
98    /// Argv segments. Values in {braces} are template variables.
99    #[serde(default)]
100    pub argv: Vec<String>,
101}
102
103/// Per-tool policy configuration.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ToolPolicyConfig {
106    /// Enables this tool policy.
107    #[serde(default = "default_true")]
108    pub enabled: bool,
109    /// Requires approval for this tool after hard denials pass.
110    #[serde(default, alias = "require_approval")]
111    pub require_confirmation: bool,
112    /// Explicitly permits side-effecting calls to skip classification-default approval.
113    #[serde(default)]
114    pub allow_without_confirmation: bool,
115    /// Message shown when tool-level approval is required.
116    #[serde(default)]
117    pub confirmation_message: Option<String>,
118    /// Maximum allowed calls per minute.
119    #[serde(default)]
120    pub rate_limit: Option<u32>,
121    /// Tool-specific timeout in milliseconds, up to [`MAX_TOOL_TIMEOUT_MS`].
122    #[serde(default)]
123    pub timeout_ms: Option<u64>,
124    /// Legacy domain allowlist mapped to domain policy.
125    #[serde(default)]
126    pub allowed_domains: Vec<String>,
127    /// Legacy domain blocklist mapped to domain policy.
128    #[serde(default)]
129    pub blocked_domains: Vec<String>,
130    /// Legacy path allowlist mapped to path policy.
131    #[serde(default)]
132    pub allowed_paths: Vec<String>,
133    /// Explicit read path allowlist for local read-only tools.
134    #[serde(default)]
135    pub read_paths: Vec<String>,
136    /// Explicit write path allowlist for local mutation tools.
137    #[serde(default)]
138    pub write_paths: Vec<String>,
139    /// Paths that override any allowlist.
140    #[serde(default)]
141    pub blocked_paths: Vec<String>,
142    /// Maximum file size read or searched by local tools.
143    #[serde(default)]
144    pub max_file_size_bytes: Option<u64>,
145    /// Maximum model-facing output characters.
146    #[serde(default)]
147    pub max_output_chars: Option<usize>,
148    /// Maximum rows or entries for list/search tools.
149    #[serde(default)]
150    pub max_results: Option<usize>,
151    /// Maximum response bytes for web fetch tools.
152    #[serde(default)]
153    pub max_response_bytes: Option<usize>,
154    /// Blocks private, localhost, link-local, and metadata network targets.
155    #[serde(default = "default_true")]
156    pub blocked_private_networks: bool,
157    /// Allowed URL schemes for network tools.
158    #[serde(default)]
159    pub allowed_schemes: Vec<String>,
160    /// Allowed URL ports for network tools.
161    #[serde(default)]
162    pub allowed_ports: Vec<u16>,
163    /// Maximum redirect count for network tools.
164    #[serde(default)]
165    pub max_redirects: Option<usize>,
166    /// Maximum files a mutation tool may change.
167    #[serde(default)]
168    pub max_changed_files: Option<usize>,
169    /// Maximum changed lines a mutation tool may produce.
170    #[serde(default)]
171    pub max_changed_lines: Option<usize>,
172    /// Maximum exact replacements a mutation tool may perform.
173    #[serde(default)]
174    pub max_replacements: Option<usize>,
175    /// Requires a matching file-read version before mutating an existing file.
176    #[serde(default)]
177    pub require_read_before_write: bool,
178    /// Allows overwriting existing files for mutation tools.
179    #[serde(default)]
180    pub overwrite_existing: bool,
181    /// Allows mutation tools to create missing parent directories.
182    #[serde(default)]
183    pub create_parent_dirs: bool,
184    /// Behavior when no write_paths allowlist is configured.
185    #[serde(default)]
186    pub no_write_policy: NoWritePolicyBehavior,
187    /// Exact argv allowlist for the command tool.
188    #[serde(default)]
189    pub allowed_commands: Vec<CommandRuleConfig>,
190    /// Argv templates for the command tool.
191    #[serde(default)]
192    pub command_templates: Vec<CommandTemplateConfig>,
193    /// Working directories allowed for command execution.
194    #[serde(default)]
195    pub working_dirs: Vec<String>,
196    /// Environment variables that may be passed from tool arguments.
197    #[serde(default)]
198    pub env_passthrough: Vec<String>,
199    /// Environment variables redacted from evidence.
200    #[serde(default)]
201    pub redact_env: Vec<String>,
202    /// Reject shell-like command strings and metacharacters.
203    #[serde(default = "default_true")]
204    pub deny_shell: bool,
205    /// Reject interactive command execution.
206    #[serde(default = "default_true")]
207    pub deny_interactive: bool,
208    /// Allows approval-based command escalation beyond the allowlist.
209    #[serde(default)]
210    pub allow_command_escalation: bool,
211    /// Parsed domain policy.
212    #[serde(default)]
213    pub domains: DomainPolicyConfig,
214    /// Normalized path policy.
215    #[serde(default)]
216    pub paths: PathPolicyConfig,
217    /// Command policy for process-backed tools.
218    #[serde(default)]
219    pub commands: CommandPolicyConfig,
220    /// Operation policy based on arguments such as operation, function, or method.
221    #[serde(default)]
222    pub operations: OperationPolicyConfig,
223    /// Custom tool settings exposed through ToolExecutionContext.custom_config.
224    #[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/// Domain allow, deny, approval, and unavailable policy lists.
276#[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/// Path allow, deny, approval, and unavailable policy lists.
289#[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/// Command allow, deny, approval, unavailable, and typed execution policy.
302#[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/// Operation allow, deny, approval, and unavailable policy lists.
347#[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/// Security decision returned by the tool security engine.
360#[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    /// Returns true when execution may continue without blocking.
371    pub fn is_allowed(&self) -> bool {
372        matches!(
373            self,
374            SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
375        )
376    }
377
378    /// Returns true when execution must not invoke the tool.
379    pub fn is_blocked(&self) -> bool {
380        matches!(
381            self,
382            SecurityCheckResult::Block { .. } | SecurityCheckResult::Unavailable { .. }
383        )
384    }
385
386    /// Converts the security result to a stable permission outcome.
387    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    /// Returns the human-readable reason or warning message.
399    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    /// Returns true when a human approval request is required.
410    pub fn requires_approval(&self) -> bool {
411        matches!(self, SecurityCheckResult::RequireConfirmation { .. })
412    }
413
414    /// Returns true when the tool or required host policy is unavailable.
415    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}