Skip to main content

ai_agents_tools/security/
config.rs

1use ai_agents_core::{AgentError, PermissionOutcome, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Runtime security configuration for tool execution.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ToolSecurityConfig {
8    /// Enables runtime tool security checks.
9    #[serde(default)]
10    pub enabled: bool,
11    /// Blocks tools without explicit policy when security is enabled.
12    #[serde(default)]
13    pub fail_closed: bool,
14    /// Default timeout for tool execution in milliseconds.
15    #[serde(default = "default_tool_timeout")]
16    pub default_timeout_ms: u64,
17    /// Per-tool policies keyed by canonical tool ID.
18    #[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    /// Validates policy values that have a framework-wide semantic domain.
35    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/// Behavior when a mutation tool has no explicit write policy.
54#[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/// Exact argv command allowed by command policy.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct CommandRuleConfig {
65    /// Full argv vector, including executable name.
66    #[serde(default)]
67    pub argv: Vec<String>,
68}
69
70/// Argv command template with literal and wildcard variable segments.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct CommandTemplateConfig {
73    /// Template name used in evidence and diagnostics.
74    pub name: String,
75    /// Argv segments. Values in {braces} are template variables.
76    #[serde(default)]
77    pub argv: Vec<String>,
78}
79
80/// Per-tool policy configuration.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ToolPolicyConfig {
83    /// Enables this tool policy.
84    #[serde(default = "default_true")]
85    pub enabled: bool,
86    /// Requires approval for this tool after hard denials pass.
87    #[serde(default, alias = "require_approval")]
88    pub require_confirmation: bool,
89    /// Explicitly permits side-effecting calls to skip classification-default approval.
90    #[serde(default)]
91    pub allow_without_confirmation: bool,
92    /// Message shown when tool-level approval is required.
93    #[serde(default)]
94    pub confirmation_message: Option<String>,
95    /// Maximum allowed calls per minute.
96    #[serde(default)]
97    pub rate_limit: Option<u32>,
98    /// Tool-specific timeout in milliseconds.
99    #[serde(default)]
100    pub timeout_ms: Option<u64>,
101    /// Legacy domain allowlist mapped to domain policy.
102    #[serde(default)]
103    pub allowed_domains: Vec<String>,
104    /// Legacy domain blocklist mapped to domain policy.
105    #[serde(default)]
106    pub blocked_domains: Vec<String>,
107    /// Legacy path allowlist mapped to path policy.
108    #[serde(default)]
109    pub allowed_paths: Vec<String>,
110    /// Explicit read path allowlist for local read-only tools.
111    #[serde(default)]
112    pub read_paths: Vec<String>,
113    /// Explicit write path allowlist for local mutation tools.
114    #[serde(default)]
115    pub write_paths: Vec<String>,
116    /// Paths that override any allowlist.
117    #[serde(default)]
118    pub blocked_paths: Vec<String>,
119    /// Maximum file size read or searched by local tools.
120    #[serde(default)]
121    pub max_file_size_bytes: Option<u64>,
122    /// Maximum model-facing output characters.
123    #[serde(default)]
124    pub max_output_chars: Option<usize>,
125    /// Maximum rows or entries for list/search tools.
126    #[serde(default)]
127    pub max_results: Option<usize>,
128    /// Maximum response bytes for web fetch tools.
129    #[serde(default)]
130    pub max_response_bytes: Option<usize>,
131    /// Blocks private, localhost, link-local, and metadata network targets.
132    #[serde(default = "default_true")]
133    pub blocked_private_networks: bool,
134    /// Allowed URL schemes for network tools.
135    #[serde(default)]
136    pub allowed_schemes: Vec<String>,
137    /// Allowed URL ports for network tools.
138    #[serde(default)]
139    pub allowed_ports: Vec<u16>,
140    /// Maximum redirect count for network tools.
141    #[serde(default)]
142    pub max_redirects: Option<usize>,
143    /// Maximum files a mutation tool may change.
144    #[serde(default)]
145    pub max_changed_files: Option<usize>,
146    /// Maximum changed lines a mutation tool may produce.
147    #[serde(default)]
148    pub max_changed_lines: Option<usize>,
149    /// Maximum exact replacements a mutation tool may perform.
150    #[serde(default)]
151    pub max_replacements: Option<usize>,
152    /// Requires a matching file-read version before mutating an existing file.
153    #[serde(default)]
154    pub require_read_before_write: bool,
155    /// Allows overwriting existing files for mutation tools.
156    #[serde(default)]
157    pub overwrite_existing: bool,
158    /// Allows mutation tools to create missing parent directories.
159    #[serde(default)]
160    pub create_parent_dirs: bool,
161    /// Behavior when no write_paths allowlist is configured.
162    #[serde(default)]
163    pub no_write_policy: NoWritePolicyBehavior,
164    /// Exact argv allowlist for the command tool.
165    #[serde(default)]
166    pub allowed_commands: Vec<CommandRuleConfig>,
167    /// Argv templates for the command tool.
168    #[serde(default)]
169    pub command_templates: Vec<CommandTemplateConfig>,
170    /// Working directories allowed for command execution.
171    #[serde(default)]
172    pub working_dirs: Vec<String>,
173    /// Environment variables that may be passed from tool arguments.
174    #[serde(default)]
175    pub env_passthrough: Vec<String>,
176    /// Environment variables redacted from evidence.
177    #[serde(default)]
178    pub redact_env: Vec<String>,
179    /// Reject shell-like command strings and metacharacters.
180    #[serde(default = "default_true")]
181    pub deny_shell: bool,
182    /// Reject interactive command execution.
183    #[serde(default = "default_true")]
184    pub deny_interactive: bool,
185    /// Allows approval-based command escalation beyond the allowlist.
186    #[serde(default)]
187    pub allow_command_escalation: bool,
188    /// Parsed domain policy.
189    #[serde(default)]
190    pub domains: DomainPolicyConfig,
191    /// Normalized path policy.
192    #[serde(default)]
193    pub paths: PathPolicyConfig,
194    /// Command policy for process-backed tools.
195    #[serde(default)]
196    pub commands: CommandPolicyConfig,
197    /// Operation policy based on arguments such as operation, function, or method.
198    #[serde(default)]
199    pub operations: OperationPolicyConfig,
200    /// Custom tool settings exposed through ToolExecutionContext.custom_config.
201    #[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/// Domain allow, deny, approval, and unavailable policy lists.
253#[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/// Path allow, deny, approval, and unavailable policy lists.
266#[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/// Command allow, deny, approval, unavailable, and typed execution policy.
279#[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/// Operation allow, deny, approval, and unavailable policy lists.
324#[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/// Security decision returned by the tool security engine.
337#[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    /// Returns true when execution may continue without blocking.
348    pub fn is_allowed(&self) -> bool {
349        matches!(
350            self,
351            SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
352        )
353    }
354
355    /// Returns true when execution must not invoke the tool.
356    pub fn is_blocked(&self) -> bool {
357        matches!(
358            self,
359            SecurityCheckResult::Block { .. } | SecurityCheckResult::Unavailable { .. }
360        )
361    }
362
363    /// Converts the security result to a stable permission outcome.
364    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    /// Returns the human-readable reason or warning message.
376    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    /// Returns true when a human approval request is required.
387    pub fn requires_approval(&self) -> bool {
388        matches!(self, SecurityCheckResult::RequireConfirmation { .. })
389    }
390
391    /// Returns true when the tool or required host policy is unavailable.
392    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}