Skip to main content

ai_agents_tools/security/
engine.rs

1use std::collections::HashMap;
2use std::net::IpAddr;
3use std::path::Path;
4use std::sync::Arc;
5use std::time::Instant;
6
7use parking_lot::RwLock;
8use tracing::debug;
9
10use super::config::*;
11use super::path::PathPolicyResolver;
12use ai_agents_core::{
13    CommandBindingKind, CommandPolicyBinding, DomainPolicyBinding, PathAccessMode,
14    PathPolicyBinding, Result, ResultLimitBinding, ResultLimitKind, ToolCallClassification,
15    ToolExecutionLimits, ToolPolicyBindings, ToolSafetyMetadata,
16};
17use serde_json::Value;
18
19#[derive(Debug, Default)]
20struct ToolCallTracker {
21    calls: HashMap<String, Vec<Instant>>,
22}
23
24impl ToolCallTracker {
25    fn admit(&mut self, tool_id: &str, rate_limit: Option<u32>) -> bool {
26        let Some(rate_limit) = rate_limit else {
27            return true;
28        };
29        let now = Instant::now();
30        let window = std::time::Duration::from_secs(60);
31        let calls = self.calls.entry(tool_id.to_string()).or_default();
32        calls.retain(|timestamp| now.duration_since(*timestamp) < window);
33        if calls.len() >= rate_limit as usize {
34            return false;
35        }
36        calls.push(now);
37        true
38    }
39
40    fn reset(&mut self) {
41        self.calls.clear();
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct ToolSecurityEngine {
47    config: ToolSecurityConfig,
48    tool_call_tracker: Arc<RwLock<ToolCallTracker>>,
49    policy_version: u64,
50}
51
52impl ToolSecurityEngine {
53    /// Creates a validated security engine and panics when host policy is invalid.
54    pub fn new(config: ToolSecurityConfig) -> Self {
55        Self::try_new(config).expect("invalid tool security configuration")
56    }
57
58    /// Creates a validated security engine with a recoverable configuration error.
59    pub fn try_new(config: ToolSecurityConfig) -> Result<Self> {
60        Self::try_new_with_policy_version(config, 1)
61    }
62
63    /// Creates a versioned validated engine and panics when host policy is invalid.
64    pub fn new_with_policy_version(config: ToolSecurityConfig, policy_version: u64) -> Self {
65        Self::try_new_with_policy_version(config, policy_version)
66            .expect("invalid tool security configuration")
67    }
68
69    /// Creates a versioned validated engine without admitting invalid policy state.
70    pub fn try_new_with_policy_version(
71        config: ToolSecurityConfig,
72        policy_version: u64,
73    ) -> Result<Self> {
74        config.validate()?;
75        Ok(Self {
76            config,
77            tool_call_tracker: Arc::new(RwLock::new(ToolCallTracker::default())),
78            policy_version,
79        })
80    }
81
82    /// Revalidates the immutable configuration owned by this engine.
83    pub fn validate(&self) -> Result<()> {
84        self.config.validate()
85    }
86
87    pub fn config(&self) -> &ToolSecurityConfig {
88        &self.config
89    }
90
91    pub fn policy_version(&self) -> u64 {
92        self.policy_version
93    }
94
95    pub fn prepare_tool_arguments(&self, tool_id: &str, args: &Value) -> Value {
96        let bindings = legacy_policy_bindings(tool_id);
97        self.prepare_tool_arguments_with_bindings(tool_id, args, &bindings)
98    }
99
100    pub fn prepare_tool_arguments_with_bindings(
101        &self,
102        tool_id: &str,
103        args: &Value,
104        bindings: &ToolPolicyBindings,
105    ) -> Value {
106        if !self.config.enabled {
107            return args.clone();
108        }
109        let mut prepared = args.clone();
110        let Some(tool_config) = self.config.tools.get(tool_id) else {
111            return prepared;
112        };
113        normalize_default_path_arguments(bindings, &mut prepared);
114        apply_policy_caps(tool_config, bindings, &mut prepared);
115        prepared
116    }
117
118    pub fn attach_internal_tool_policy(&self, _tool_id: &str, args: &Value) -> Value {
119        args.clone()
120    }
121
122    pub fn get_tool_output_cap(
123        &self,
124        tool_id: &str,
125        classification_cap: Option<usize>,
126    ) -> Option<usize> {
127        let policy_cap = self
128            .config
129            .enabled
130            .then(|| self.config.tools.get(tool_id))
131            .flatten()
132            .and_then(|config| config.max_output_chars);
133        min_optional_usize(classification_cap, policy_cap)
134    }
135
136    pub fn effective_limits(
137        &self,
138        tool_id: &str,
139        safety: &ToolSafetyMetadata,
140        classification: &ToolCallClassification,
141    ) -> ToolExecutionLimits {
142        let policy = self
143            .config
144            .enabled
145            .then(|| self.config.tools.get(tool_id))
146            .flatten();
147        ToolExecutionLimits {
148            timeout_ms: Some(self.get_tool_timeout(tool_id)),
149            max_output_chars: min_optional_usize(
150                classification.max_output_chars,
151                policy.and_then(|config| config.max_output_chars),
152            ),
153            max_result_chars: safety.max_result_size_chars,
154            max_results: policy.and_then(|config| config.max_results),
155            max_file_size_bytes: policy.and_then(|config| config.max_file_size_bytes),
156            max_response_bytes: policy.and_then(|config| config.max_response_bytes),
157            max_redirects: policy.and_then(|config| config.max_redirects),
158            max_replacements: policy.and_then(|config| config.max_replacements),
159            max_changed_files: policy.and_then(|config| config.max_changed_files),
160            max_changed_lines: policy.and_then(|config| config.max_changed_lines),
161        }
162    }
163
164    pub fn policy_snapshot(&self, tool_id: &str) -> Value {
165        if !self.config.enabled {
166            return Value::Null;
167        }
168        self.config
169            .tools
170            .get(tool_id)
171            .and_then(|config| serde_json::to_value(config).ok())
172            .unwrap_or(Value::Null)
173    }
174
175    /// Returns the approval message required by call classification defaults.
176    pub fn classification_approval_message(
177        &self,
178        tool_id: &str,
179        classification: &ToolCallClassification,
180    ) -> Option<String> {
181        if !classification.requires_approval || classification.read_only {
182            return None;
183        }
184        if !matches!(
185            classification.operation,
186            ai_agents_core::ToolOperationKind::Write
187                | ai_agents_core::ToolOperationKind::Edit
188                | ai_agents_core::ToolOperationKind::Delete
189                | ai_agents_core::ToolOperationKind::Patch
190                | ai_agents_core::ToolOperationKind::Command
191        ) {
192            return None;
193        }
194        let tool_config = self
195            .config
196            .enabled
197            .then(|| self.config.tools.get(tool_id))
198            .flatten();
199        if tool_config.is_some_and(|config| config.allow_without_confirmation) {
200            return None;
201        }
202        Some(format!(
203            "Confirm {} operation for tool '{}' ?",
204            format!("{:?}", classification.operation).to_ascii_lowercase(),
205            tool_id
206        ))
207    }
208
209    pub fn custom_config(&self, tool_id: &str) -> Value {
210        if !self.config.enabled {
211            return Value::Null;
212        }
213        self.config
214            .tools
215            .get(tool_id)
216            .map(|config| Value::Object(config.config.clone().into_iter().collect()))
217            .unwrap_or(Value::Null)
218    }
219
220    pub async fn check_tool_execution(
221        &self,
222        tool_id: &str,
223        args: &serde_json::Value,
224    ) -> Result<SecurityCheckResult> {
225        let bindings = legacy_policy_bindings(tool_id);
226        self.check_tool_execution_with_bindings(tool_id, args, &bindings)
227            .await
228    }
229
230    pub async fn check_tool_execution_with_bindings(
231        &self,
232        tool_id: &str,
233        args: &serde_json::Value,
234        bindings: &ToolPolicyBindings,
235    ) -> Result<SecurityCheckResult> {
236        let validation = self
237            .validate_tool_execution_with_bindings(tool_id, args, bindings)
238            .await?;
239        if validation.is_allowed() {
240            let admission = self.admit_tool_execution(tool_id);
241            if !admission.is_allowed() {
242                return Ok(admission);
243            }
244        }
245        Ok(validation)
246    }
247
248    /// Validates policy without consuming rate-limit admission.
249    pub async fn validate_tool_execution_with_bindings(
250        &self,
251        tool_id: &str,
252        args: &serde_json::Value,
253        bindings: &ToolPolicyBindings,
254    ) -> Result<SecurityCheckResult> {
255        if !self.config.enabled {
256            return Ok(SecurityCheckResult::Allow);
257        }
258
259        let tool_config = match self.config.tools.get(tool_id) {
260            Some(config) => config,
261            None if self.config.fail_closed => {
262                return Ok(SecurityCheckResult::Block {
263                    reason: format!("Tool '{}' has no explicit security policy", tool_id),
264                });
265            }
266            None => {
267                debug!(tool_id = %tool_id, "Tool execution allowed by legacy open policy");
268                return Ok(SecurityCheckResult::Allow);
269            }
270        };
271
272        if !tool_config.enabled {
273            return Ok(SecurityCheckResult::Unavailable {
274                reason: format!("Tool '{}' is disabled", tool_id),
275            });
276        }
277
278        if let Some(result) =
279            validate_policy_bindings(tool_id, tool_config, bindings, self.config.fail_closed)
280        {
281            return Ok(result);
282        }
283
284        if let Some(result) = self.check_domain_policy(tool_id, tool_config, args, bindings) {
285            return Ok(result);
286        }
287
288        if let Some(result) = self.check_path_policy(tool_id, tool_config, args, bindings) {
289            return Ok(result);
290        }
291
292        if let Some(result) = self.check_operation_policy(tool_id, tool_config, args, bindings) {
293            return Ok(result);
294        }
295
296        if let Some(result) = self.check_command_policy(tool_id, tool_config, args, bindings) {
297            return Ok(result);
298        }
299
300        if tool_config.require_confirmation {
301            let message = tool_config
302                .confirmation_message
303                .clone()
304                .unwrap_or_else(|| format!("Confirm execution of tool '{}' ?", tool_id));
305            return Ok(SecurityCheckResult::RequireConfirmation { message });
306        }
307
308        debug!(tool_id = %tool_id, "Tool execution allowed by policy validation");
309        Ok(SecurityCheckResult::Allow)
310    }
311
312    pub fn admit_tool_execution(&self, tool_id: &str) -> SecurityCheckResult {
313        if !self.config.enabled {
314            return SecurityCheckResult::Allow;
315        }
316        let tool_config = match self.config.tools.get(tool_id) {
317            Some(config) => config,
318            None if self.config.fail_closed => {
319                return SecurityCheckResult::Block {
320                    reason: format!("Tool '{}' has no explicit security policy", tool_id),
321                };
322            }
323            None => {
324                self.tool_call_tracker.write().admit(tool_id, None);
325                return SecurityCheckResult::Allow;
326            }
327        };
328        if !tool_config.enabled {
329            return SecurityCheckResult::Unavailable {
330                reason: format!("Tool '{}' is disabled", tool_id),
331            };
332        }
333        let mut tracker = self.tool_call_tracker.write();
334        if !tracker.admit(tool_id, tool_config.rate_limit) {
335            let rate_limit = tool_config.rate_limit.unwrap_or_default();
336            return SecurityCheckResult::Block {
337                reason: format!(
338                    "Rate limit exceeded for tool '{}': {} calls per minute",
339                    tool_id, rate_limit
340                ),
341            };
342        }
343        debug!(tool_id = %tool_id, "Tool execution admitted");
344        SecurityCheckResult::Allow
345    }
346
347    pub fn check_command_execution(
348        &self,
349        tool_id: &str,
350        command: &str,
351        args: &[String],
352    ) -> SecurityCheckResult {
353        if !self.config.enabled {
354            return SecurityCheckResult::Allow;
355        }
356        let Some(tool_config) = self.config.tools.get(tool_id) else {
357            return if self.config.fail_closed {
358                SecurityCheckResult::Block {
359                    reason: format!("Tool '{}' has no explicit command policy", tool_id),
360                }
361            } else {
362                SecurityCheckResult::Allow
363            };
364        };
365        let value = serde_json::json!({
366            "command": command,
367            "argv": std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>()
368        });
369        let bindings = legacy_policy_bindings(tool_id);
370        self.check_command_policy(tool_id, tool_config, &value, &bindings)
371            .unwrap_or(SecurityCheckResult::Allow)
372    }
373
374    fn check_domain_policy(
375        &self,
376        tool_id: &str,
377        tool_config: &ToolPolicyConfig,
378        args: &serde_json::Value,
379        bindings: &ToolPolicyBindings,
380    ) -> Option<SecurityCheckResult> {
381        let values = bound_domain_values(args, bindings);
382        if values.is_empty() {
383            return missing_bound_value_result(
384                tool_id,
385                "domain",
386                domain_policy_configured(tool_config),
387                self.config.fail_closed,
388            );
389        }
390        for value in values {
391            let parsed = if value.is_url {
392                match reqwest::Url::parse(&value.value) {
393                    Ok(parsed) => parsed,
394                    Err(_) => {
395                        return Some(SecurityCheckResult::Block {
396                            reason: format!("URL is invalid for tool '{}'", tool_id),
397                        });
398                    }
399                }
400            } else {
401                match reqwest::Url::parse(&format!("https://{}", value.value)) {
402                    Ok(parsed) => parsed,
403                    Err(_) => {
404                        return Some(SecurityCheckResult::Block {
405                            reason: format!("Domain is invalid for tool '{}'", tool_id),
406                        });
407                    }
408                }
409            };
410            let host = parsed.host_str().map(normalize_host)?;
411
412            if !tool_config.allowed_schemes.is_empty()
413                && !tool_config
414                    .allowed_schemes
415                    .iter()
416                    .any(|scheme| scheme.eq_ignore_ascii_case(parsed.scheme()))
417            {
418                return Some(SecurityCheckResult::Block {
419                    reason: format!(
420                        "URL scheme '{}' is not allowed for tool '{}'",
421                        parsed.scheme(),
422                        tool_id
423                    ),
424                });
425            }
426
427            if !tool_config.allowed_ports.is_empty() {
428                let port = parsed.port_or_known_default().unwrap_or(0);
429                if !tool_config.allowed_ports.contains(&port) {
430                    return Some(SecurityCheckResult::Block {
431                        reason: format!(
432                            "URL port '{}' is not allowed for tool '{}'",
433                            port, tool_id
434                        ),
435                    });
436                }
437            }
438
439            if tool_config.blocked_private_networks && host_is_private_or_local(&host) {
440                return Some(SecurityCheckResult::Block {
441                    reason: format!(
442                        "Private, localhost, link-local, or metadata host is blocked for tool '{}'",
443                        tool_id
444                    ),
445                });
446            }
447
448            let denied = tool_config
449                .blocked_domains
450                .iter()
451                .chain(tool_config.domains.deny.iter());
452            for pattern in denied {
453                if host_matches(pattern, &host) {
454                    return Some(SecurityCheckResult::Block {
455                        reason: format!("Domain '{}' is blocked for tool '{}'", pattern, tool_id),
456                    });
457                }
458            }
459
460            for pattern in &tool_config.domains.unavailable {
461                if host_matches(pattern, &host) {
462                    return Some(SecurityCheckResult::Unavailable {
463                        reason: format!(
464                            "Domain '{}' is unavailable for tool '{}'",
465                            pattern, tool_id
466                        ),
467                    });
468                }
469            }
470
471            for pattern in &tool_config.domains.requires_approval {
472                if host_matches(pattern, &host) {
473                    return Some(SecurityCheckResult::RequireConfirmation {
474                        message: format!(
475                            "Confirm access to domain '{}' for tool '{}' ?",
476                            host, tool_id
477                        ),
478                    });
479                }
480            }
481
482            let allowed: Vec<&String> = tool_config
483                .allowed_domains
484                .iter()
485                .chain(tool_config.domains.allow.iter())
486                .collect();
487            if !allowed.is_empty() && !allowed.iter().any(|pattern| host_matches(pattern, &host)) {
488                return Some(SecurityCheckResult::Block {
489                    reason: format!("URL domain not in allowed list for tool '{}'", tool_id),
490                });
491            }
492        }
493
494        None
495    }
496
497    fn check_path_policy(
498        &self,
499        tool_id: &str,
500        tool_config: &ToolPolicyConfig,
501        args: &serde_json::Value,
502        bindings: &ToolPolicyBindings,
503    ) -> Option<SecurityCheckResult> {
504        let values = bound_path_values(args, bindings);
505        if values.is_empty() {
506            return missing_bound_value_result(
507                tool_id,
508                "path",
509                path_policy_configured(tool_config),
510                self.config.fail_closed,
511            );
512        }
513        let resolver = match PathPolicyResolver::new() {
514            Ok(resolver) => resolver,
515            Err(error) => return Some(path_resolution_block(tool_id, error)),
516        };
517        for value in values {
518            if let Err(error) = resolver.resolve_path(Path::new(&value.path)) {
519                return Some(path_resolution_block(tool_id, error));
520            }
521
522            for pattern in tool_config
523                .blocked_paths
524                .iter()
525                .chain(tool_config.paths.deny.iter())
526            {
527                match resolver.matches_restriction(Path::new(&value.path), Path::new(pattern)) {
528                    Ok(true) => {
529                        return Some(SecurityCheckResult::Block {
530                            reason: format!("Path is blocked for tool '{}'", tool_id),
531                        });
532                    }
533                    Ok(false) => {}
534                    Err(error) => return Some(path_resolution_block(tool_id, error)),
535                }
536            }
537
538            for pattern in tool_config.paths.unavailable.iter() {
539                match resolver.matches_restriction(Path::new(&value.path), Path::new(pattern)) {
540                    Ok(true) => {
541                        return Some(SecurityCheckResult::Unavailable {
542                            reason: format!("Path is unavailable for tool '{}'", tool_id),
543                        });
544                    }
545                    Ok(false) => {}
546                    Err(error) => return Some(path_resolution_block(tool_id, error)),
547                }
548            }
549
550            for pattern in tool_config.paths.requires_approval.iter() {
551                match resolver.matches_restriction(Path::new(&value.path), Path::new(pattern)) {
552                    Ok(true) => {
553                        return Some(SecurityCheckResult::RequireConfirmation {
554                            message: format!(
555                                "Confirm access to path '{}' for tool '{}' ?",
556                                value.path, tool_id
557                            ),
558                        });
559                    }
560                    Ok(false) => {}
561                    Err(error) => return Some(path_resolution_block(tool_id, error)),
562                }
563            }
564
565            if !matches!(value.kind, ai_agents_core::PathBindingKind::Cwd)
566                && matches!(
567                    value.mode,
568                    PathAccessMode::Write | PathAccessMode::ReadWrite
569                )
570                && !has_write_allowlist(tool_config)
571            {
572                let dry_run = args
573                    .get("dry_run")
574                    .and_then(Value::as_bool)
575                    .unwrap_or(false);
576                if matches!(tool_config.no_write_policy, NoWritePolicyBehavior::Deny) || !dry_run {
577                    return Some(SecurityCheckResult::Block {
578                        reason: format!(
579                            "Tool '{}' cannot mutate paths without an explicit write_paths policy",
580                            tool_id
581                        ),
582                    });
583                }
584            }
585
586            let allowed = allowed_paths_for_value(tool_config, &value);
587            if matches!(value.kind, ai_agents_core::PathBindingKind::Cwd) && allowed.is_empty() {
588                return Some(SecurityCheckResult::Block {
589                    reason: format!(
590                        "Tool '{}' requires an explicit working_dirs policy for command cwd",
591                        tool_id
592                    ),
593                });
594            }
595            if !allowed.is_empty() {
596                let mut matches_allowed = false;
597                for pattern in allowed {
598                    match resolver.is_allowed(Path::new(&value.path), Path::new(pattern)) {
599                        Ok(true) => {
600                            matches_allowed = true;
601                            break;
602                        }
603                        Ok(false) => {}
604                        Err(error) => return Some(path_resolution_block(tool_id, error)),
605                    }
606                }
607                if !matches_allowed {
608                    return Some(SecurityCheckResult::Block {
609                        reason: format!("Path not in allowed list for tool '{}'", tool_id),
610                    });
611                }
612            }
613        }
614
615        None
616    }
617
618    fn check_operation_policy(
619        &self,
620        tool_id: &str,
621        tool_config: &ToolPolicyConfig,
622        args: &serde_json::Value,
623        bindings: &ToolPolicyBindings,
624    ) -> Option<SecurityCheckResult> {
625        let operations = bound_operation_values(args, bindings);
626        if operations.is_empty() {
627            return missing_bound_value_result(
628                tool_id,
629                "operation",
630                operation_policy_configured(tool_config),
631                self.config.fail_closed,
632            );
633        }
634        for operation in operations {
635            if contains_casefold(&tool_config.operations.deny, &operation) {
636                return Some(SecurityCheckResult::Block {
637                    reason: format!(
638                        "Operation '{}' is blocked for tool '{}'",
639                        operation, tool_id
640                    ),
641                });
642            }
643            if contains_casefold(&tool_config.operations.unavailable, &operation) {
644                return Some(SecurityCheckResult::Unavailable {
645                    reason: format!(
646                        "Operation '{}' is unavailable for tool '{}'",
647                        operation, tool_id
648                    ),
649                });
650            }
651            if contains_casefold(&tool_config.operations.requires_approval, &operation) {
652                return Some(SecurityCheckResult::RequireConfirmation {
653                    message: format!("Confirm operation '{}' for tool '{}' ?", operation, tool_id),
654                });
655            }
656            if !tool_config.operations.allow.is_empty()
657                && !contains_casefold(&tool_config.operations.allow, &operation)
658            {
659                return Some(SecurityCheckResult::Block {
660                    reason: format!(
661                        "Operation '{}' is not allowed for tool '{}'",
662                        operation, tool_id
663                    ),
664                });
665            }
666        }
667
668        None
669    }
670
671    fn check_command_policy(
672        &self,
673        tool_id: &str,
674        tool_config: &ToolPolicyConfig,
675        args: &serde_json::Value,
676        bindings: &ToolPolicyBindings,
677    ) -> Option<SecurityCheckResult> {
678        let commands = bound_command_values(args, bindings);
679        if commands.is_empty() {
680            return missing_bound_value_result(
681                tool_id,
682                "command",
683                command_policy_configured(tool_config),
684                self.config.fail_closed,
685            );
686        }
687        for command in commands {
688            let display = command.display();
689            let command_name = command.command_name();
690
691            if command.is_string
692                && command_denies_shell(tool_config)
693                && contains_shell_syntax(&display)
694            {
695                return Some(SecurityCheckResult::Block {
696                    reason: format!(
697                        "Command '{}' uses shell syntax denied for tool '{}'",
698                        display, tool_id
699                    ),
700                });
701            }
702            if contains_casefold(&tool_config.commands.deny, &display)
703                || contains_casefold(&tool_config.commands.deny, &command_name)
704            {
705                return Some(SecurityCheckResult::Block {
706                    reason: format!("Command '{}' is blocked for tool '{}'", display, tool_id),
707                });
708            }
709            if contains_casefold(&tool_config.commands.unavailable, &display)
710                || contains_casefold(&tool_config.commands.unavailable, &command_name)
711            {
712                return Some(SecurityCheckResult::Unavailable {
713                    reason: format!(
714                        "Command '{}' is unavailable for tool '{}'",
715                        display, tool_id
716                    ),
717                });
718            }
719            if contains_casefold(&tool_config.commands.requires_approval, &display)
720                || contains_casefold(&tool_config.commands.requires_approval, &command_name)
721            {
722                return Some(SecurityCheckResult::RequireConfirmation {
723                    message: format!("Confirm command '{}' for tool '{}' ?", display, tool_id),
724                });
725            }
726            let has_exact_allowlist = command_has_exact_allowlist(tool_config);
727            if command_requires_exact_allowlist(tool_id) && !has_exact_allowlist {
728                return Some(SecurityCheckResult::Block {
729                    reason: format!(
730                        "Tool '{}' requires allowed_commands or command_templates before execution",
731                        tool_id
732                    ),
733                });
734            }
735            if has_exact_allowlist {
736                if !command_matches_allowed(tool_config, &command.argv) {
737                    if command_allows_escalation(tool_config) {
738                        return Some(SecurityCheckResult::RequireConfirmation {
739                            message: format!(
740                                "Confirm command '{}' outside the exact allowlist for tool '{}' ?",
741                                display, tool_id
742                            ),
743                        });
744                    }
745                    return Some(SecurityCheckResult::Block {
746                        reason: format!(
747                            "Command '{}' is not in the exact argv allowlist for tool '{}'",
748                            display, tool_id
749                        ),
750                    });
751                }
752                continue;
753            }
754            if !tool_config.commands.allow.is_empty()
755                && !contains_casefold(&tool_config.commands.allow, &display)
756                && !contains_casefold(&tool_config.commands.allow, &command_name)
757            {
758                return Some(SecurityCheckResult::Block {
759                    reason: format!(
760                        "Command '{}' is not allowed for tool '{}'",
761                        display, tool_id
762                    ),
763                });
764            }
765        }
766
767        None
768    }
769
770    pub fn get_tool_timeout(&self, tool_id: &str) -> u64 {
771        self.config
772            .tools
773            .get(tool_id)
774            .and_then(|c| c.timeout_ms)
775            .unwrap_or(self.config.default_timeout_ms)
776    }
777
778    pub fn reset_session(&self) {
779        self.tool_call_tracker.write().reset();
780    }
781}
782
783impl Default for ToolSecurityEngine {
784    fn default() -> Self {
785        Self::new(ToolSecurityConfig::default())
786    }
787}
788
789fn normalize_default_path_arguments(bindings: &ToolPolicyBindings, args: &mut Value) {
790    for binding in &bindings.path_fields {
791        let Some(default_path) = binding.default_path.as_deref() else {
792            continue;
793        };
794        if value_at_path(args, &binding.field).is_none() {
795            set_root_value(
796                args,
797                &binding.field,
798                Value::String(default_path.to_string()),
799            );
800        }
801    }
802}
803
804fn apply_policy_caps(config: &ToolPolicyConfig, bindings: &ToolPolicyBindings, args: &mut Value) {
805    let Some(obj) = args.as_object_mut() else {
806        return;
807    };
808    for binding in &bindings.result_limit_fields {
809        match binding.kind {
810            ResultLimitKind::MaxResults | ResultLimitKind::Pagination => {
811                apply_usize_cap(obj, &binding.field, config.max_results);
812            }
813            ResultLimitKind::MaxLines => {
814                apply_usize_cap(obj, &binding.field, config.max_results);
815            }
816            ResultLimitKind::MaxOutputChars => {
817                apply_usize_cap(obj, &binding.field, config.max_output_chars);
818            }
819            ResultLimitKind::MaxFileSizeBytes => {
820                apply_u64_cap(obj, &binding.field, config.max_file_size_bytes);
821            }
822            ResultLimitKind::MaxResponseBytes => {
823                apply_usize_cap(obj, &binding.field, config.max_response_bytes);
824            }
825            ResultLimitKind::MaxRedirects => {
826                apply_usize_cap(obj, &binding.field, config.max_redirects);
827            }
828            ResultLimitKind::MaxReplacements => {
829                apply_usize_cap(obj, &binding.field, config.max_replacements);
830            }
831            ResultLimitKind::MaxChangedFiles => {
832                apply_usize_cap(obj, &binding.field, config.max_changed_files);
833            }
834            ResultLimitKind::MaxChangedLines => {
835                apply_usize_cap(obj, &binding.field, config.max_changed_lines);
836            }
837        }
838    }
839}
840
841fn legacy_policy_bindings(tool_id: &str) -> ToolPolicyBindings {
842    match tool_id {
843        "glob" => ToolPolicyBindings {
844            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
845            result_limit_fields: vec![ResultLimitBinding::new(
846                "max_results",
847                ResultLimitKind::MaxResults,
848            )],
849            ..Default::default()
850        },
851        "grep" => ToolPolicyBindings {
852            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
853            result_limit_fields: vec![
854                ResultLimitBinding::new("max_results", ResultLimitKind::MaxResults),
855                ResultLimitBinding::new("max_file_size_bytes", ResultLimitKind::MaxFileSizeBytes),
856                ResultLimitBinding::new("max_output_chars", ResultLimitKind::MaxOutputChars),
857            ],
858            ..Default::default()
859        },
860        "file_read" => ToolPolicyBindings {
861            path_fields: vec![PathPolicyBinding::read("path")],
862            result_limit_fields: vec![
863                ResultLimitBinding::new("max_bytes", ResultLimitKind::MaxFileSizeBytes),
864                ResultLimitBinding::new("max_lines", ResultLimitKind::MaxLines),
865            ],
866            ..Default::default()
867        },
868        "file_list" => ToolPolicyBindings {
869            path_fields: vec![PathPolicyBinding::read("path")],
870            result_limit_fields: vec![ResultLimitBinding::new(
871                "max_results",
872                ResultLimitKind::MaxResults,
873            )],
874            ..Default::default()
875        },
876        "file_info" => ToolPolicyBindings {
877            path_fields: vec![PathPolicyBinding::read("path")],
878            ..Default::default()
879        },
880        "git_status" => ToolPolicyBindings {
881            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
882            result_limit_fields: vec![ResultLimitBinding::new(
883                "max_results",
884                ResultLimitKind::MaxResults,
885            )],
886            ..Default::default()
887        },
888        "git_diff" => ToolPolicyBindings {
889            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
890            result_limit_fields: vec![ResultLimitBinding::new(
891                "max_output_chars",
892                ResultLimitKind::MaxOutputChars,
893            )],
894            ..Default::default()
895        },
896        "diagnostics" => ToolPolicyBindings {
897            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
898            result_limit_fields: vec![ResultLimitBinding::new(
899                "max_results",
900                ResultLimitKind::MaxResults,
901            )],
902            ..Default::default()
903        },
904        "web_fetch" => ToolPolicyBindings {
905            domain_fields: vec![DomainPolicyBinding::url("url")],
906            result_limit_fields: vec![
907                ResultLimitBinding::new("max_chars", ResultLimitKind::MaxOutputChars),
908                ResultLimitBinding::new("max_response_bytes", ResultLimitKind::MaxResponseBytes),
909                ResultLimitBinding::new("max_redirects", ResultLimitKind::MaxRedirects),
910            ],
911            ..Default::default()
912        },
913        "http" => ToolPolicyBindings {
914            domain_fields: vec![DomainPolicyBinding::url("url")],
915            operation_fields: vec!["method".to_string()],
916            ..Default::default()
917        },
918        "file" => ToolPolicyBindings {
919            path_fields: vec![PathPolicyBinding::read_write("path")],
920            operation_fields: vec!["operation".to_string()],
921            ..Default::default()
922        },
923        "file_write" => ToolPolicyBindings {
924            path_fields: vec![PathPolicyBinding::write("path")],
925            result_limit_fields: vec![
926                ResultLimitBinding::new("max_changed_files", ResultLimitKind::MaxChangedFiles),
927                ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
928            ],
929            ..Default::default()
930        },
931        "file_edit" => ToolPolicyBindings {
932            path_fields: vec![PathPolicyBinding::write("path")],
933            result_limit_fields: vec![
934                ResultLimitBinding::new("max_replacements", ResultLimitKind::MaxReplacements),
935                ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
936            ],
937            ..Default::default()
938        },
939        "patch" => ToolPolicyBindings {
940            path_fields: vec![
941                ai_agents_core::PathPolicyBinding::new(
942                    "base_path",
943                    PathAccessMode::Write,
944                    ai_agents_core::PathBindingKind::PatchBase,
945                )
946                .with_default_path("."),
947            ],
948            result_limit_fields: vec![
949                ResultLimitBinding::new("max_changed_files", ResultLimitKind::MaxChangedFiles),
950                ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
951            ],
952            ..Default::default()
953        },
954        "copy_path" => ToolPolicyBindings {
955            path_fields: vec![
956                PathPolicyBinding::read("source_path"),
957                PathPolicyBinding::write("destination_path"),
958            ],
959            ..Default::default()
960        },
961        "move_path" => ToolPolicyBindings {
962            path_fields: vec![
963                PathPolicyBinding::read_write("source_path"),
964                PathPolicyBinding::write("destination_path"),
965            ],
966            ..Default::default()
967        },
968        "delete_path" => ToolPolicyBindings {
969            path_fields: vec![PathPolicyBinding::write("path")],
970            ..Default::default()
971        },
972        "command" => ToolPolicyBindings {
973            command_fields: vec![
974                CommandPolicyBinding::command("command"),
975                CommandPolicyBinding::argv("argv"),
976                CommandPolicyBinding::env("env"),
977            ],
978            path_fields: vec![
979                ai_agents_core::PathPolicyBinding::new(
980                    "cwd",
981                    PathAccessMode::ReadWrite,
982                    ai_agents_core::PathBindingKind::Cwd,
983                )
984                .with_default_path("."),
985            ],
986            result_limit_fields: vec![ResultLimitBinding::new(
987                "max_output_chars",
988                ResultLimitKind::MaxOutputChars,
989            )],
990            ..Default::default()
991        },
992        _ => ToolPolicyBindings::default(),
993    }
994}
995
996#[derive(Debug, Clone)]
997struct BoundPathValue {
998    path: String,
999    mode: PathAccessMode,
1000    kind: ai_agents_core::PathBindingKind,
1001}
1002
1003#[derive(Debug, Clone)]
1004struct BoundDomainValue {
1005    value: String,
1006    is_url: bool,
1007}
1008
1009#[derive(Debug, Clone)]
1010struct BoundCommandValue {
1011    argv: Vec<String>,
1012    is_string: bool,
1013}
1014
1015impl BoundCommandValue {
1016    fn display(&self) -> String {
1017        self.argv.join(" ")
1018    }
1019
1020    fn command_name(&self) -> String {
1021        self.argv.first().cloned().unwrap_or_default()
1022    }
1023}
1024
1025fn validate_policy_bindings(
1026    tool_id: &str,
1027    config: &ToolPolicyConfig,
1028    bindings: &ToolPolicyBindings,
1029    fail_closed: bool,
1030) -> Option<SecurityCheckResult> {
1031    if !fail_closed {
1032        return None;
1033    }
1034    if path_policy_configured(config) && !bindings.has_path_bindings() {
1035        return Some(SecurityCheckResult::Block {
1036            reason: format!(
1037                "path policy configured for {} but tool exposes no path policy bindings",
1038                tool_id
1039            ),
1040        });
1041    }
1042    if domain_policy_configured(config) && !bindings.has_domain_bindings() {
1043        return Some(SecurityCheckResult::Block {
1044            reason: format!(
1045                "domain policy configured for {} but tool exposes no domain policy bindings",
1046                tool_id
1047            ),
1048        });
1049    }
1050    if command_policy_configured(config) && !bindings.has_command_bindings() {
1051        return Some(SecurityCheckResult::Block {
1052            reason: format!(
1053                "command policy configured for {} but tool exposes no command policy bindings",
1054                tool_id
1055            ),
1056        });
1057    }
1058    if operation_policy_configured(config) && !bindings.has_operation_bindings() {
1059        return Some(SecurityCheckResult::Block {
1060            reason: format!(
1061                "operation policy configured for {} but tool exposes no operation policy bindings",
1062                tool_id
1063            ),
1064        });
1065    }
1066    if result_limit_policy_configured(config) && !bindings.has_result_limit_bindings() {
1067        return Some(SecurityCheckResult::Block {
1068            reason: format!(
1069                "result-limit policy configured for {} but tool exposes no result-limit policy bindings",
1070                tool_id
1071            ),
1072        });
1073    }
1074    None
1075}
1076
1077fn missing_bound_value_result(
1078    tool_id: &str,
1079    policy_kind: &str,
1080    configured: bool,
1081    fail_closed: bool,
1082) -> Option<SecurityCheckResult> {
1083    if configured && fail_closed {
1084        Some(SecurityCheckResult::Block {
1085            reason: format!(
1086                "{} policy configured for {} but no bound {} argument was present",
1087                policy_kind, tool_id, policy_kind
1088            ),
1089        })
1090    } else {
1091        None
1092    }
1093}
1094
1095fn path_policy_configured(config: &ToolPolicyConfig) -> bool {
1096    !config.allowed_paths.is_empty()
1097        || !config.read_paths.is_empty()
1098        || !config.write_paths.is_empty()
1099        || !config.working_dirs.is_empty()
1100        || !config.commands.working_dirs.is_empty()
1101        || !config.blocked_paths.is_empty()
1102        || !config.paths.allow.is_empty()
1103        || !config.paths.deny.is_empty()
1104        || !config.paths.requires_approval.is_empty()
1105        || !config.paths.unavailable.is_empty()
1106}
1107
1108fn domain_policy_configured(config: &ToolPolicyConfig) -> bool {
1109    !config.allowed_domains.is_empty()
1110        || !config.blocked_domains.is_empty()
1111        || !config.allowed_schemes.is_empty()
1112        || !config.allowed_ports.is_empty()
1113        || !config.domains.allow.is_empty()
1114        || !config.domains.deny.is_empty()
1115        || !config.domains.requires_approval.is_empty()
1116        || !config.domains.unavailable.is_empty()
1117}
1118
1119fn command_policy_configured(config: &ToolPolicyConfig) -> bool {
1120    !config.commands.allow.is_empty()
1121        || !config.commands.deny.is_empty()
1122        || !config.commands.requires_approval.is_empty()
1123        || !config.commands.unavailable.is_empty()
1124        || !config.commands.allowed_commands.is_empty()
1125        || !config.commands.templates.is_empty()
1126        || !config.allowed_commands.is_empty()
1127        || !config.command_templates.is_empty()
1128        || !config.env_passthrough.is_empty()
1129        || !config.commands.env_passthrough.is_empty()
1130}
1131
1132fn operation_policy_configured(config: &ToolPolicyConfig) -> bool {
1133    !config.operations.allow.is_empty()
1134        || !config.operations.deny.is_empty()
1135        || !config.operations.requires_approval.is_empty()
1136        || !config.operations.unavailable.is_empty()
1137}
1138
1139fn result_limit_policy_configured(config: &ToolPolicyConfig) -> bool {
1140    // max_output_chars is enforced by the shared executor after execution.
1141    config.max_file_size_bytes.is_some()
1142        || config.max_results.is_some()
1143        || config.max_response_bytes.is_some()
1144        || config.max_redirects.is_some()
1145        || config.max_replacements.is_some()
1146        || config.max_changed_files.is_some()
1147        || config.max_changed_lines.is_some()
1148}
1149
1150fn bound_path_values(args: &Value, bindings: &ToolPolicyBindings) -> Vec<BoundPathValue> {
1151    let mut values = Vec::new();
1152    for binding in &bindings.path_fields {
1153        collect_path_binding_values(args, binding, &mut values);
1154    }
1155    values
1156}
1157
1158fn collect_path_binding_values(
1159    args: &Value,
1160    binding: &PathPolicyBinding,
1161    values: &mut Vec<BoundPathValue>,
1162) {
1163    let value = value_at_path(args, &binding.field).cloned().or_else(|| {
1164        binding
1165            .default_path
1166            .as_ref()
1167            .map(|path| Value::String(path.clone()))
1168    });
1169    let Some(value) = value else {
1170        return;
1171    };
1172    match value {
1173        Value::String(path) => values.push(BoundPathValue {
1174            path,
1175            mode: effective_path_mode(args, binding),
1176            kind: binding.kind,
1177        }),
1178        Value::Array(items) => {
1179            for item in items {
1180                if let Some(path) = item.as_str() {
1181                    values.push(BoundPathValue {
1182                        path: path.to_string(),
1183                        mode: effective_path_mode(args, binding),
1184                        kind: binding.kind,
1185                    });
1186                }
1187            }
1188        }
1189        _ => {}
1190    }
1191}
1192
1193fn bound_domain_values(args: &Value, bindings: &ToolPolicyBindings) -> Vec<BoundDomainValue> {
1194    let mut values = Vec::new();
1195    for binding in &bindings.domain_fields {
1196        collect_domain_binding_values(args, binding, &mut values);
1197    }
1198    values
1199}
1200
1201fn collect_domain_binding_values(
1202    args: &Value,
1203    binding: &DomainPolicyBinding,
1204    values: &mut Vec<BoundDomainValue>,
1205) {
1206    let Some(value) = value_at_path(args, &binding.field) else {
1207        return;
1208    };
1209    match value {
1210        Value::String(value) => values.push(BoundDomainValue {
1211            value: value.clone(),
1212            is_url: binding.is_url,
1213        }),
1214        Value::Array(items) => {
1215            for item in items {
1216                if let Some(value) = item.as_str() {
1217                    values.push(BoundDomainValue {
1218                        value: value.to_string(),
1219                        is_url: binding.is_url,
1220                    });
1221                }
1222            }
1223        }
1224        _ => {}
1225    }
1226}
1227
1228fn bound_operation_values(args: &Value, bindings: &ToolPolicyBindings) -> Vec<String> {
1229    bindings
1230        .operation_fields
1231        .iter()
1232        .filter_map(|field| value_at_path(args, field).and_then(Value::as_str))
1233        .map(|value| value.trim().to_ascii_lowercase())
1234        .collect()
1235}
1236
1237fn bound_command_values(args: &Value, bindings: &ToolPolicyBindings) -> Vec<BoundCommandValue> {
1238    let mut values = Vec::new();
1239    for binding in &bindings.command_fields {
1240        collect_command_binding_values(args, binding, &mut values);
1241    }
1242    values
1243}
1244
1245fn collect_command_binding_values(
1246    args: &Value,
1247    binding: &CommandPolicyBinding,
1248    values: &mut Vec<BoundCommandValue>,
1249) {
1250    let Some(value) = value_at_path(args, &binding.field) else {
1251        return;
1252    };
1253    match binding.kind {
1254        CommandBindingKind::CommandString => {
1255            if let Some(command) = value.as_str() {
1256                if let Some(argv) = parse_command_words(command) {
1257                    values.push(BoundCommandValue {
1258                        argv,
1259                        is_string: true,
1260                    });
1261                } else {
1262                    values.push(BoundCommandValue {
1263                        argv: vec![command.to_string()],
1264                        is_string: true,
1265                    });
1266                }
1267            }
1268        }
1269        CommandBindingKind::Argv => {
1270            if let Some(argv) = value.as_array().map(|items| {
1271                items
1272                    .iter()
1273                    .filter_map(Value::as_str)
1274                    .map(str::to_string)
1275                    .collect::<Vec<_>>()
1276            }) && !argv.is_empty()
1277            {
1278                values.push(BoundCommandValue {
1279                    argv,
1280                    is_string: false,
1281                });
1282            }
1283        }
1284        CommandBindingKind::Cwd
1285        | CommandBindingKind::TemplateVariable
1286        | CommandBindingKind::Env => {}
1287    }
1288}
1289
1290fn allowed_paths_for_value<'a>(
1291    config: &'a ToolPolicyConfig,
1292    value: &BoundPathValue,
1293) -> Vec<&'a String> {
1294    if matches!(value.kind, ai_agents_core::PathBindingKind::Cwd) {
1295        return config
1296            .working_dirs
1297            .iter()
1298            .chain(config.commands.working_dirs.iter())
1299            .collect();
1300    }
1301    let mut allowed: Vec<&String> = config
1302        .allowed_paths
1303        .iter()
1304        .chain(config.paths.allow.iter())
1305        .collect();
1306    match value.mode {
1307        PathAccessMode::Read => allowed.extend(config.read_paths.iter()),
1308        PathAccessMode::Write | PathAccessMode::ReadWrite => {
1309            allowed.extend(config.write_paths.iter());
1310        }
1311    }
1312    allowed
1313}
1314
1315fn has_write_allowlist(config: &ToolPolicyConfig) -> bool {
1316    !config.write_paths.is_empty()
1317        || !config.allowed_paths.is_empty()
1318        || !config.paths.allow.is_empty()
1319}
1320
1321fn effective_path_mode(args: &Value, binding: &PathPolicyBinding) -> PathAccessMode {
1322    if !matches!(binding.mode, PathAccessMode::ReadWrite) {
1323        return binding.mode;
1324    }
1325    let operation = args
1326        .get("operation")
1327        .and_then(Value::as_str)
1328        .unwrap_or_default()
1329        .to_ascii_lowercase();
1330    match operation.as_str() {
1331        "read" | "exists" | "list" | "info" => PathAccessMode::Read,
1332        "write" | "append" | "mkdir" | "delete" | "edit" | "patch" => PathAccessMode::Write,
1333        _ => binding.mode,
1334    }
1335}
1336
1337fn value_at_path<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
1338    let mut current = value;
1339    for segment in field.split('.') {
1340        if segment.is_empty() {
1341            return None;
1342        }
1343        current = current.get(segment)?;
1344    }
1345    Some(current)
1346}
1347
1348fn set_root_value(args: &mut Value, field: &str, value: Value) {
1349    if field.contains('.') {
1350        return;
1351    }
1352    let Some(obj) = args.as_object_mut() else {
1353        return;
1354    };
1355    obj.insert(field.to_string(), value);
1356}
1357
1358fn apply_usize_cap(obj: &mut serde_json::Map<String, Value>, key: &str, cap: Option<usize>) {
1359    let Some(cap) = cap else {
1360        return;
1361    };
1362    let effective = obj
1363        .get(key)
1364        .and_then(|value| value.as_u64())
1365        .map(|value| value.min(cap as u64) as usize)
1366        .unwrap_or(cap);
1367    obj.insert(key.to_string(), Value::from(effective));
1368}
1369
1370fn apply_u64_cap(obj: &mut serde_json::Map<String, Value>, key: &str, cap: Option<u64>) {
1371    let Some(cap) = cap else {
1372        return;
1373    };
1374    let effective = obj
1375        .get(key)
1376        .and_then(|value| value.as_u64())
1377        .map(|value| value.min(cap))
1378        .unwrap_or(cap);
1379    obj.insert(key.to_string(), Value::from(effective));
1380}
1381
1382fn min_optional_usize(left: Option<usize>, right: Option<usize>) -> Option<usize> {
1383    match (left, right) {
1384        (Some(left), Some(right)) => Some(left.min(right)),
1385        (Some(left), None) => Some(left),
1386        (None, Some(right)) => Some(right),
1387        (None, None) => None,
1388    }
1389}
1390
1391fn normalize_host(host: &str) -> String {
1392    host.trim_end_matches('.').to_ascii_lowercase()
1393}
1394
1395fn host_matches(pattern: &str, host: &str) -> bool {
1396    let pattern = normalize_host(pattern.trim_start_matches("*."));
1397    host == pattern || host.ends_with(&format!(".{}", pattern))
1398}
1399
1400fn path_resolution_block(
1401    tool_id: &str,
1402    error: super::path::PathResolutionError,
1403) -> SecurityCheckResult {
1404    SecurityCheckResult::Block {
1405        reason: format!(
1406            "Path policy resolution failed for tool '{}': {}",
1407            tool_id, error
1408        ),
1409    }
1410}
1411
1412fn contains_casefold(values: &[String], needle: &str) -> bool {
1413    values
1414        .iter()
1415        .any(|value| value.eq_ignore_ascii_case(needle))
1416}
1417
1418fn command_denies_shell(config: &ToolPolicyConfig) -> bool {
1419    config.deny_shell || config.commands.deny_shell
1420}
1421
1422fn command_allows_escalation(config: &ToolPolicyConfig) -> bool {
1423    config.allow_command_escalation || config.commands.allow_escalation
1424}
1425
1426fn command_requires_exact_allowlist(tool_id: &str) -> bool {
1427    tool_id == "command"
1428}
1429
1430fn command_has_exact_allowlist(config: &ToolPolicyConfig) -> bool {
1431    !config.allowed_commands.is_empty()
1432        || !config.commands.allowed_commands.is_empty()
1433        || !config.command_templates.is_empty()
1434        || !config.commands.templates.is_empty()
1435}
1436
1437fn command_matches_allowed(config: &ToolPolicyConfig, argv: &[String]) -> bool {
1438    config
1439        .allowed_commands
1440        .iter()
1441        .chain(config.commands.allowed_commands.iter())
1442        .any(|rule| rule.argv == argv)
1443        || config
1444            .command_templates
1445            .iter()
1446            .chain(config.commands.templates.iter())
1447            .any(|template| command_matches_template(&template.argv, argv))
1448}
1449
1450fn command_matches_template(template: &[String], argv: &[String]) -> bool {
1451    template.len() == argv.len()
1452        && template.iter().zip(argv.iter()).all(|(expected, actual)| {
1453            (expected.starts_with('{') && expected.ends_with('}')) || expected == actual
1454        })
1455}
1456
1457fn contains_shell_syntax(value: &str) -> bool {
1458    const DENIED: &[char] = &[';', '&', '|', '<', '>', '`', '$', '\n', '\r'];
1459    value.chars().any(|ch| DENIED.contains(&ch))
1460        || value.contains("$(")
1461        || value.contains("${")
1462        || value.contains("<(")
1463        || value.contains(">(")
1464}
1465
1466fn parse_command_words(value: &str) -> Option<Vec<String>> {
1467    let mut words = Vec::new();
1468    let mut current = String::new();
1469    let mut quote: Option<char> = None;
1470    for ch in value.chars() {
1471        match (quote, ch) {
1472            (Some(q), c) if c == q => quote = None,
1473            (Some(_), c) => current.push(c),
1474            (None, '\'' | '"') => quote = Some(ch),
1475            (None, c) if c.is_whitespace() => {
1476                if !current.is_empty() {
1477                    words.push(std::mem::take(&mut current));
1478                }
1479            }
1480            (None, c) => current.push(c),
1481        }
1482    }
1483    if quote.is_some() {
1484        return None;
1485    }
1486    if !current.is_empty() {
1487        words.push(current);
1488    }
1489    (!words.is_empty()).then_some(words)
1490}
1491
1492fn host_is_private_or_local(host: &str) -> bool {
1493    if matches!(
1494        host,
1495        "localhost"
1496            | "metadata"
1497            | "metadata.google.internal"
1498            | "169.254.169.254"
1499            | "100.100.100.200"
1500    ) || host.ends_with(".localhost")
1501    {
1502        return true;
1503    }
1504    match host.parse::<IpAddr>() {
1505        Ok(IpAddr::V4(ip)) => {
1506            ip.is_private()
1507                || ip.is_loopback()
1508                || ip.is_link_local()
1509                || ip.is_multicast()
1510                || ip.is_documentation()
1511                || ip.octets() == [169, 254, 169, 254]
1512        }
1513        Ok(IpAddr::V6(ip)) => {
1514            ip.is_loopback()
1515                || ip.is_unspecified()
1516                || ip.is_multicast()
1517                || ip.segments()[0] & 0xfe00 == 0xfc00
1518                || ip.segments()[0] & 0xffc0 == 0xfe80
1519        }
1520        Err(_) => false,
1521    }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526    use super::*;
1527
1528    fn enabled_security_config() -> ToolSecurityConfig {
1529        ToolSecurityConfig {
1530            enabled: true,
1531            ..Default::default()
1532        }
1533    }
1534
1535    #[test]
1536    fn test_default_engine() {
1537        let engine = ToolSecurityEngine::default();
1538        assert!(!engine.config().enabled);
1539    }
1540
1541    #[tokio::test]
1542    async fn test_tool_domain_blocking() {
1543        let mut config = enabled_security_config();
1544
1545        let http_config = ToolPolicyConfig {
1546            blocked_domains: vec!["evil.com".to_string()],
1547            ..Default::default()
1548        };
1549        config.tools.insert("http".to_string(), http_config);
1550
1551        let engine = ToolSecurityEngine::new(config);
1552
1553        let args = serde_json::json!({"url": "https://evil.com/api"});
1554        let result = engine.check_tool_execution("http", &args).await.unwrap();
1555        assert!(result.is_blocked());
1556
1557        let args = serde_json::json!({"url": "https://not-evil.com/api"});
1558        let result = engine.check_tool_execution("http", &args).await.unwrap();
1559        assert!(result.is_allowed());
1560    }
1561
1562    #[tokio::test]
1563    async fn test_tool_allowed_domains() {
1564        let mut config = enabled_security_config();
1565
1566        let http_config = ToolPolicyConfig {
1567            allowed_domains: vec!["api.example.com".to_string()],
1568            ..Default::default()
1569        };
1570        config.tools.insert("http".to_string(), http_config);
1571
1572        let engine = ToolSecurityEngine::new(config);
1573
1574        let args = serde_json::json!({"url": "https://api.example.com/v1"});
1575        let result = engine.check_tool_execution("http", &args).await.unwrap();
1576        assert!(result.is_allowed());
1577
1578        let args = serde_json::json!({"url": "https://other.com/api"});
1579        let result = engine.check_tool_execution("http", &args).await.unwrap();
1580        assert!(result.is_blocked());
1581    }
1582
1583    #[tokio::test]
1584    async fn test_tool_disabled() {
1585        let mut config = enabled_security_config();
1586
1587        let tool_config = ToolPolicyConfig {
1588            enabled: false,
1589            ..Default::default()
1590        };
1591        config.tools.insert("dangerous".to_string(), tool_config);
1592
1593        let engine = ToolSecurityEngine::new(config);
1594
1595        let result = engine
1596            .check_tool_execution("dangerous", &serde_json::json!({}))
1597            .await
1598            .unwrap();
1599        assert!(result.is_blocked());
1600        assert!(result.is_unavailable());
1601    }
1602
1603    #[tokio::test]
1604    async fn test_tool_confirmation_required() {
1605        let mut config = enabled_security_config();
1606
1607        let tool_config = ToolPolicyConfig {
1608            require_confirmation: true,
1609            confirmation_message: Some("Are you sure?".to_string()),
1610            ..Default::default()
1611        };
1612        config.tools.insert("delete".to_string(), tool_config);
1613
1614        let engine = ToolSecurityEngine::new(config);
1615
1616        let result = engine
1617            .check_tool_execution("delete", &serde_json::json!({}))
1618            .await
1619            .unwrap();
1620
1621        match result {
1622            SecurityCheckResult::RequireConfirmation { message } => {
1623                assert_eq!(message, "Are you sure?");
1624            }
1625            _ => panic!("Expected RequireConfirmation"),
1626        }
1627    }
1628
1629    #[test]
1630    fn test_get_tool_timeout() {
1631        let mut config = ToolSecurityConfig {
1632            default_timeout_ms: 5000,
1633            ..Default::default()
1634        };
1635
1636        let tool_config = ToolPolicyConfig {
1637            timeout_ms: Some(10000),
1638            ..Default::default()
1639        };
1640        config.tools.insert("slow".to_string(), tool_config);
1641
1642        let engine = ToolSecurityEngine::new(config);
1643
1644        assert_eq!(engine.get_tool_timeout("slow"), 10000);
1645        assert_eq!(engine.get_tool_timeout("other"), 5000);
1646    }
1647
1648    #[tokio::test]
1649    async fn test_path_restrictions() {
1650        let directory = tempfile::tempdir().unwrap();
1651        let allowed_root = directory.path().join("allowed");
1652        std::fs::create_dir(&allowed_root).unwrap();
1653        let allowed_path = allowed_root.join("test.txt");
1654        let denied_path = directory.path().join("denied/test.txt");
1655        let mut config = enabled_security_config();
1656
1657        let tool_config = ToolPolicyConfig {
1658            allowed_paths: vec![allowed_root.to_string_lossy().into_owned()],
1659            ..Default::default()
1660        };
1661        config.tools.insert("file_write".to_string(), tool_config);
1662
1663        let engine = ToolSecurityEngine::new(config);
1664
1665        let args = serde_json::json!({"path": allowed_path});
1666        let result = engine
1667            .check_tool_execution("file_write", &args)
1668            .await
1669            .unwrap();
1670        assert!(result.is_allowed(), "{result:?}");
1671
1672        let args = serde_json::json!({"path": denied_path});
1673        let result = engine
1674            .check_tool_execution("file_write", &args)
1675            .await
1676            .unwrap();
1677        assert!(result.is_blocked(), "{result:?}");
1678    }
1679
1680    #[tokio::test]
1681    async fn test_operation_policy() {
1682        let mut config = enabled_security_config();
1683        let tool_config = ToolPolicyConfig {
1684            operations: OperationPolicyConfig {
1685                deny: vec!["delete".to_string()],
1686                requires_approval: vec!["write".to_string()],
1687                ..Default::default()
1688            },
1689            ..Default::default()
1690        };
1691        config.tools.insert("file".to_string(), tool_config);
1692        let engine = ToolSecurityEngine::new(config);
1693
1694        let result = engine
1695            .check_tool_execution("file", &serde_json::json!({"operation": "delete"}))
1696            .await
1697            .unwrap();
1698        assert!(result.is_blocked());
1699
1700        let result = engine
1701            .check_tool_execution("file", &serde_json::json!({"operation": "write"}))
1702            .await
1703            .unwrap();
1704        assert!(result.requires_approval());
1705    }
1706
1707    #[tokio::test]
1708    async fn omitted_optional_path_uses_default_for_policy() {
1709        let mut config = enabled_security_config();
1710        let tool_config = ToolPolicyConfig {
1711            read_paths: vec!["./crates".to_string()],
1712            ..Default::default()
1713        };
1714        config.tools.insert("grep".to_string(), tool_config);
1715        let engine = ToolSecurityEngine::new(config);
1716
1717        let result = engine
1718            .check_tool_execution("grep", &serde_json::json!({"pattern": "Tool"}))
1719            .await
1720            .unwrap();
1721        assert!(result.is_blocked());
1722
1723        let prepared =
1724            engine.prepare_tool_arguments("grep", &serde_json::json!({"pattern": "Tool"}));
1725        assert_eq!(prepared.get("path").and_then(Value::as_str), Some("."));
1726    }
1727
1728    #[tokio::test]
1729    async fn fail_closed_requires_path_bindings_for_custom_tools() {
1730        let mut config = ToolSecurityConfig {
1731            fail_closed: true,
1732            ..enabled_security_config()
1733        };
1734        let tool_config = ToolPolicyConfig {
1735            read_paths: vec!["./allowed".to_string()],
1736            ..Default::default()
1737        };
1738        config
1739            .tools
1740            .insert("custom_search".to_string(), tool_config);
1741        let engine = ToolSecurityEngine::new(config);
1742
1743        let result = engine
1744            .check_tool_execution_with_bindings(
1745                "custom_search",
1746                &serde_json::json!({"path": "./allowed/file.txt"}),
1747                &ToolPolicyBindings::default(),
1748            )
1749            .await
1750            .unwrap();
1751
1752        assert!(result.is_blocked());
1753        assert!(
1754            result
1755                .reason()
1756                .unwrap_or_default()
1757                .contains("tool exposes no path policy bindings")
1758        );
1759    }
1760
1761    #[tokio::test]
1762    async fn custom_path_bindings_enforce_blocked_paths() {
1763        let mut config = ToolSecurityConfig {
1764            fail_closed: true,
1765            ..enabled_security_config()
1766        };
1767        let tool_config = ToolPolicyConfig {
1768            read_paths: vec!["./allowed".to_string()],
1769            blocked_paths: vec!["./allowed/private".to_string()],
1770            ..Default::default()
1771        };
1772        config
1773            .tools
1774            .insert("custom_search".to_string(), tool_config);
1775        let engine = ToolSecurityEngine::new(config);
1776        let bindings = ToolPolicyBindings {
1777            path_fields: vec![PathPolicyBinding::read("root")],
1778            ..Default::default()
1779        };
1780
1781        let allowed = engine
1782            .check_tool_execution_with_bindings(
1783                "custom_search",
1784                &serde_json::json!({"root": "./allowed/src"}),
1785                &bindings,
1786            )
1787            .await
1788            .unwrap();
1789        assert!(allowed.is_allowed());
1790
1791        let blocked = engine
1792            .check_tool_execution_with_bindings(
1793                "custom_search",
1794                &serde_json::json!({"root": "./allowed/private/secrets.txt"}),
1795                &bindings,
1796            )
1797            .await
1798            .unwrap();
1799        assert!(blocked.is_blocked());
1800    }
1801
1802    #[cfg(unix)]
1803    #[tokio::test]
1804    async fn blocked_path_cannot_be_reached_through_symlink_alias() {
1805        use std::os::unix::fs::symlink;
1806
1807        let root = tempfile::tempdir().unwrap();
1808        let private = root.path().join("private");
1809        let public = root.path().join("public");
1810        std::fs::create_dir_all(&private).unwrap();
1811        std::fs::create_dir_all(&public).unwrap();
1812        symlink(&private, public.join("alias")).unwrap();
1813
1814        let mut config = ToolSecurityConfig {
1815            fail_closed: true,
1816            ..enabled_security_config()
1817        };
1818        let tool_config = ToolPolicyConfig {
1819            read_paths: vec![root.path().to_string_lossy().into_owned()],
1820            blocked_paths: vec![private.to_string_lossy().into_owned()],
1821            ..Default::default()
1822        };
1823        config
1824            .tools
1825            .insert("custom_search".to_string(), tool_config);
1826        let engine = ToolSecurityEngine::new(config);
1827        let bindings = ToolPolicyBindings {
1828            path_fields: vec![PathPolicyBinding::read("root")],
1829            ..Default::default()
1830        };
1831
1832        let result = engine
1833            .check_tool_execution_with_bindings(
1834                "custom_search",
1835                &serde_json::json!({"root": public.join("alias/secret.txt")}),
1836                &bindings,
1837            )
1838            .await
1839            .unwrap();
1840
1841        assert!(result.is_blocked());
1842    }
1843
1844    #[cfg(unix)]
1845    #[tokio::test]
1846    async fn path_restrictions_keep_results_through_symlink_aliases() {
1847        use std::os::unix::fs::symlink;
1848
1849        let root = tempfile::tempdir().unwrap();
1850        let workspace = root.path().join("workspace");
1851        let restricted = root.path().join("restricted");
1852        std::fs::create_dir(&workspace).unwrap();
1853        std::fs::create_dir(&restricted).unwrap();
1854        symlink(&restricted, workspace.join("alias")).unwrap();
1855        let candidate = workspace.join("alias/secret.txt");
1856        let bindings = ToolPolicyBindings {
1857            path_fields: vec![PathPolicyBinding::read("root")],
1858            ..Default::default()
1859        };
1860
1861        let mut denied_config = enabled_security_config();
1862        denied_config.tools.insert(
1863            "custom_search".to_string(),
1864            ToolPolicyConfig {
1865                read_paths: vec![restricted.to_string_lossy().into_owned()],
1866                blocked_paths: vec![restricted.to_string_lossy().into_owned()],
1867                ..Default::default()
1868            },
1869        );
1870        let denied = ToolSecurityEngine::new(denied_config)
1871            .check_tool_execution_with_bindings(
1872                "custom_search",
1873                &serde_json::json!({"root": candidate}),
1874                &bindings,
1875            )
1876            .await
1877            .unwrap();
1878        assert!(denied.is_blocked());
1879        assert!(!denied.is_unavailable());
1880
1881        let mut unavailable_config = enabled_security_config();
1882        unavailable_config.tools.insert(
1883            "custom_search".to_string(),
1884            ToolPolicyConfig {
1885                read_paths: vec![restricted.to_string_lossy().into_owned()],
1886                paths: PathPolicyConfig {
1887                    unavailable: vec![restricted.to_string_lossy().into_owned()],
1888                    ..Default::default()
1889                },
1890                ..Default::default()
1891            },
1892        );
1893        let unavailable = ToolSecurityEngine::new(unavailable_config)
1894            .check_tool_execution_with_bindings(
1895                "custom_search",
1896                &serde_json::json!({"root": candidate}),
1897                &bindings,
1898            )
1899            .await
1900            .unwrap();
1901        assert!(unavailable.is_unavailable());
1902
1903        let mut approval_config = enabled_security_config();
1904        approval_config.tools.insert(
1905            "custom_search".to_string(),
1906            ToolPolicyConfig {
1907                read_paths: vec![restricted.to_string_lossy().into_owned()],
1908                paths: PathPolicyConfig {
1909                    requires_approval: vec![restricted.to_string_lossy().into_owned()],
1910                    ..Default::default()
1911                },
1912                ..Default::default()
1913            },
1914        );
1915        let approval = ToolSecurityEngine::new(approval_config)
1916            .check_tool_execution_with_bindings(
1917                "custom_search",
1918                &serde_json::json!({"root": candidate}),
1919                &bindings,
1920            )
1921            .await
1922            .unwrap();
1923        assert!(approval.requires_approval());
1924    }
1925
1926    #[tokio::test]
1927    async fn copy_and_move_bindings_enforce_source_and_destination_roots() {
1928        let root = tempfile::tempdir().unwrap();
1929        let readable = root.path().join("readable");
1930        let writable = root.path().join("writable");
1931        let outside = root.path().join("outside");
1932        std::fs::create_dir(&readable).unwrap();
1933        std::fs::create_dir(&writable).unwrap();
1934        std::fs::create_dir(&outside).unwrap();
1935
1936        let mut copy_config = enabled_security_config();
1937        copy_config.tools.insert(
1938            "copy_path".to_string(),
1939            ToolPolicyConfig {
1940                read_paths: vec![readable.to_string_lossy().into_owned()],
1941                write_paths: vec![writable.to_string_lossy().into_owned()],
1942                ..Default::default()
1943            },
1944        );
1945        let copy_engine = ToolSecurityEngine::new(copy_config);
1946        let copy_bindings = legacy_policy_bindings("copy_path");
1947        let copy_allowed = copy_engine
1948            .check_tool_execution_with_bindings(
1949                "copy_path",
1950                &serde_json::json!({
1951                    "source_path": readable.join("source.txt"),
1952                    "destination_path": writable.join("destination.txt")
1953                }),
1954                &copy_bindings,
1955            )
1956            .await
1957            .unwrap();
1958        assert!(copy_allowed.is_allowed());
1959
1960        let copy_source_blocked = copy_engine
1961            .check_tool_execution_with_bindings(
1962                "copy_path",
1963                &serde_json::json!({
1964                    "source_path": outside.join("source.txt"),
1965                    "destination_path": writable.join("destination.txt")
1966                }),
1967                &copy_bindings,
1968            )
1969            .await
1970            .unwrap();
1971        assert!(copy_source_blocked.is_blocked());
1972
1973        let copy_destination_blocked = copy_engine
1974            .check_tool_execution_with_bindings(
1975                "copy_path",
1976                &serde_json::json!({
1977                    "source_path": readable.join("source.txt"),
1978                    "destination_path": outside.join("destination.txt")
1979                }),
1980                &copy_bindings,
1981            )
1982            .await
1983            .unwrap();
1984        assert!(copy_destination_blocked.is_blocked());
1985
1986        let mut move_config = enabled_security_config();
1987        move_config.tools.insert(
1988            "move_path".to_string(),
1989            ToolPolicyConfig {
1990                read_paths: vec![readable.to_string_lossy().into_owned()],
1991                write_paths: vec![writable.to_string_lossy().into_owned()],
1992                ..Default::default()
1993            },
1994        );
1995        let move_engine = ToolSecurityEngine::new(move_config);
1996        let move_bindings = legacy_policy_bindings("move_path");
1997        let move_allowed = move_engine
1998            .check_tool_execution_with_bindings(
1999                "move_path",
2000                &serde_json::json!({
2001                    "source_path": writable.join("source.txt"),
2002                    "destination_path": writable.join("destination.txt")
2003                }),
2004                &move_bindings,
2005            )
2006            .await
2007            .unwrap();
2008        assert!(move_allowed.is_allowed());
2009
2010        let move_source_blocked = move_engine
2011            .check_tool_execution_with_bindings(
2012                "move_path",
2013                &serde_json::json!({
2014                    "source_path": readable.join("source.txt"),
2015                    "destination_path": writable.join("destination.txt")
2016                }),
2017                &move_bindings,
2018            )
2019            .await
2020            .unwrap();
2021        assert!(move_source_blocked.is_blocked());
2022
2023        let move_destination_blocked = move_engine
2024            .check_tool_execution_with_bindings(
2025                "move_path",
2026                &serde_json::json!({
2027                    "source_path": writable.join("source.txt"),
2028                    "destination_path": outside.join("destination.txt")
2029                }),
2030                &move_bindings,
2031            )
2032            .await
2033            .unwrap();
2034        assert!(move_destination_blocked.is_blocked());
2035    }
2036
2037    #[test]
2038    fn custom_config_is_exposed_separately() {
2039        let mut config = enabled_security_config();
2040        let mut tool_config = ToolPolicyConfig::default();
2041        tool_config
2042            .config
2043            .insert("backend".to_string(), serde_json::json!("tantivy"));
2044        config.tools.insert("my_search".to_string(), tool_config);
2045        let engine = ToolSecurityEngine::new(config);
2046
2047        assert_eq!(engine.custom_config("my_search")["backend"], "tantivy");
2048    }
2049
2050    #[test]
2051    fn policy_caps_are_applied_as_upper_bounds() {
2052        let mut config = enabled_security_config();
2053        let tool_config = ToolPolicyConfig {
2054            max_results: Some(5),
2055            max_file_size_bytes: Some(1024),
2056            max_output_chars: Some(1000),
2057            ..Default::default()
2058        };
2059        config.tools.insert("grep".to_string(), tool_config);
2060        let engine = ToolSecurityEngine::new(config);
2061
2062        let prepared = engine.prepare_tool_arguments(
2063            "grep",
2064            &serde_json::json!({
2065                "pattern": "Tool",
2066                "path": ".",
2067                "max_results": 50,
2068                "max_file_size_bytes": 8192,
2069                "max_output_chars": 20000
2070            }),
2071        );
2072        assert_eq!(prepared.get("max_results").and_then(Value::as_u64), Some(5));
2073        let lower = engine.prepare_tool_arguments(
2074            "grep",
2075            &serde_json::json!({"pattern": "Tool", "max_results": 3}),
2076        );
2077        assert_eq!(lower.get("max_results").and_then(Value::as_u64), Some(3));
2078        assert_eq!(
2079            prepared.get("max_file_size_bytes").and_then(Value::as_u64),
2080            Some(1024)
2081        );
2082        assert_eq!(
2083            prepared.get("max_output_chars").and_then(Value::as_u64),
2084            Some(1000)
2085        );
2086    }
2087
2088    #[test]
2089    fn invalid_result_limit_is_rejected_before_engine_construction() {
2090        let mut config = enabled_security_config();
2091        config.tools.insert(
2092            "web_search".to_string(),
2093            ToolPolicyConfig {
2094                max_results: Some(0),
2095                ..Default::default()
2096            },
2097        );
2098        let error = ToolSecurityEngine::try_new(config).unwrap_err();
2099        assert!(
2100            error
2101                .to_string()
2102                .contains("max_results must be greater than 0")
2103        );
2104    }
2105
2106    #[tokio::test]
2107    async fn fail_closed_blocks_missing_result_limit_bindings() {
2108        let mut config = ToolSecurityConfig {
2109            fail_closed: true,
2110            ..enabled_security_config()
2111        };
2112        let tool_config = ToolPolicyConfig {
2113            max_results: Some(5),
2114            ..Default::default()
2115        };
2116        config
2117            .tools
2118            .insert("custom_search".to_string(), tool_config);
2119        let engine = ToolSecurityEngine::new(config);
2120
2121        let result = engine
2122            .check_tool_execution_with_bindings(
2123                "custom_search",
2124                &serde_json::json!({"query": "rust"}),
2125                &ToolPolicyBindings::default(),
2126            )
2127            .await
2128            .unwrap();
2129
2130        assert!(result.is_blocked());
2131        assert!(result.reason().unwrap().contains("result-limit policy"));
2132    }
2133
2134    #[tokio::test]
2135    async fn fail_closed_allows_configured_result_limit_bindings() {
2136        let mut config = ToolSecurityConfig {
2137            fail_closed: true,
2138            ..enabled_security_config()
2139        };
2140        let tool_config = ToolPolicyConfig {
2141            max_results: Some(5),
2142            ..Default::default()
2143        };
2144        config
2145            .tools
2146            .insert("custom_search".to_string(), tool_config);
2147        let engine = ToolSecurityEngine::new(config);
2148        let bindings = ToolPolicyBindings {
2149            result_limit_fields: vec![ResultLimitBinding::new(
2150                "limit",
2151                ResultLimitKind::MaxResults,
2152            )],
2153            ..Default::default()
2154        };
2155
2156        let result = engine
2157            .check_tool_execution_with_bindings(
2158                "custom_search",
2159                &serde_json::json!({"query": "rust", "limit": 10}),
2160                &bindings,
2161            )
2162            .await
2163            .unwrap();
2164
2165        assert!(result.is_allowed());
2166    }
2167
2168    #[tokio::test]
2169    async fn read_paths_do_not_authorize_file_write() {
2170        let mut config = enabled_security_config();
2171        let tool_config = ToolPolicyConfig {
2172            read_paths: vec!["./workspace".to_string()],
2173            no_write_policy: NoWritePolicyBehavior::Deny,
2174            ..Default::default()
2175        };
2176        config.tools.insert("file_write".to_string(), tool_config);
2177        let engine = ToolSecurityEngine::new(config);
2178
2179        let result = engine
2180            .check_tool_execution_with_bindings(
2181                "file_write",
2182                &serde_json::json!({"path": "./workspace/out.txt", "dry_run": false}),
2183                &legacy_policy_bindings("file_write"),
2184            )
2185            .await
2186            .unwrap();
2187
2188        assert!(result.is_blocked());
2189    }
2190
2191    #[tokio::test]
2192    async fn command_cwd_requires_working_dir_allowlist() {
2193        let mut config = enabled_security_config();
2194        let tool_config = ToolPolicyConfig {
2195            read_paths: vec![".".to_string()],
2196            allowed_commands: vec![CommandRuleConfig {
2197                argv: vec!["cargo".to_string(), "fmt".to_string(), "--all".to_string()],
2198            }],
2199            ..Default::default()
2200        };
2201        config.tools.insert("command".to_string(), tool_config);
2202        let engine = ToolSecurityEngine::new(config);
2203
2204        let result = engine
2205            .check_tool_execution_with_bindings(
2206                "command",
2207                &serde_json::json!({"argv": ["cargo", "fmt", "--all"], "cwd": "."}),
2208                &legacy_policy_bindings("command"),
2209            )
2210            .await
2211            .unwrap();
2212
2213        assert!(result.is_blocked());
2214    }
2215
2216    #[tokio::test]
2217    async fn command_requires_exact_argv_allowlist() {
2218        let mut config = enabled_security_config();
2219        let tool_config = ToolPolicyConfig {
2220            allow_without_confirmation: true,
2221            working_dirs: vec![".".to_string()],
2222            ..Default::default()
2223        };
2224        config.tools.insert("command".to_string(), tool_config);
2225        let engine = ToolSecurityEngine::new(config);
2226
2227        let result = engine
2228            .check_tool_execution_with_bindings(
2229                "command",
2230                &serde_json::json!({"argv": ["cargo", "fmt", "--all"], "cwd": "."}),
2231                &legacy_policy_bindings("command"),
2232            )
2233            .await
2234            .unwrap();
2235
2236        assert!(result.is_blocked());
2237        assert!(
2238            result
2239                .reason()
2240                .unwrap()
2241                .contains("requires allowed_commands or command_templates")
2242        );
2243    }
2244
2245    #[tokio::test]
2246    async fn command_exact_argv_allowlist_is_enforced() {
2247        let mut config = enabled_security_config();
2248        let tool_config = ToolPolicyConfig {
2249            allowed_commands: vec![CommandRuleConfig {
2250                argv: vec!["cargo".to_string(), "fmt".to_string(), "--all".to_string()],
2251            }],
2252            working_dirs: vec![".".to_string()],
2253            ..Default::default()
2254        };
2255        config.tools.insert("command".to_string(), tool_config);
2256        let engine = ToolSecurityEngine::new(config);
2257
2258        let allowed = engine
2259            .check_tool_execution_with_bindings(
2260                "command",
2261                &serde_json::json!({"argv": ["cargo", "fmt", "--all"], "cwd": "."}),
2262                &legacy_policy_bindings("command"),
2263            )
2264            .await
2265            .unwrap();
2266        assert!(allowed.is_allowed());
2267
2268        let blocked = engine
2269            .check_tool_execution_with_bindings(
2270                "command",
2271                &serde_json::json!({"argv": ["cargo", "test"], "cwd": "."}),
2272                &legacy_policy_bindings("command"),
2273            )
2274            .await
2275            .unwrap();
2276        assert!(blocked.is_blocked());
2277    }
2278
2279    #[tokio::test]
2280    async fn validation_does_not_consume_rate_limit_admission() {
2281        let mut config = enabled_security_config();
2282        let tool_config = ToolPolicyConfig {
2283            rate_limit: Some(1),
2284            ..Default::default()
2285        };
2286        config.tools.insert("limited".to_string(), tool_config);
2287        let engine = ToolSecurityEngine::new(config);
2288        let bindings = legacy_policy_bindings("limited");
2289
2290        for _ in 0..3 {
2291            let result = engine
2292                .validate_tool_execution_with_bindings("limited", &serde_json::json!({}), &bindings)
2293                .await
2294                .unwrap();
2295            assert!(result.is_allowed());
2296        }
2297        assert!(engine.admit_tool_execution("limited").is_allowed());
2298        assert!(engine.admit_tool_execution("limited").is_blocked());
2299    }
2300
2301    #[tokio::test]
2302    async fn public_check_preserves_rate_limit_admission() {
2303        let mut config = enabled_security_config();
2304        let tool_config = ToolPolicyConfig {
2305            rate_limit: Some(1),
2306            ..Default::default()
2307        };
2308        config.tools.insert("limited".to_string(), tool_config);
2309        let engine = ToolSecurityEngine::new(config);
2310
2311        let first = engine
2312            .check_tool_execution("limited", &serde_json::json!({}))
2313            .await
2314            .unwrap();
2315        let second = engine
2316            .check_tool_execution("limited", &serde_json::json!({}))
2317            .await
2318            .unwrap();
2319
2320        assert!(first.is_allowed());
2321        assert!(second.is_blocked());
2322    }
2323
2324    #[test]
2325    fn concurrent_rate_limit_admission_is_atomic() {
2326        let mut config = enabled_security_config();
2327        let tool_config = ToolPolicyConfig {
2328            rate_limit: Some(1),
2329            ..Default::default()
2330        };
2331        config.tools.insert("limited".to_string(), tool_config);
2332        let engine = Arc::new(ToolSecurityEngine::new_with_policy_version(config, 17));
2333        let barrier = Arc::new(std::sync::Barrier::new(8));
2334        let handles = (0..8)
2335            .map(|_| {
2336                let engine = Arc::clone(&engine);
2337                let barrier = Arc::clone(&barrier);
2338                std::thread::spawn(move || {
2339                    barrier.wait();
2340                    engine.admit_tool_execution("limited").is_allowed()
2341                })
2342            })
2343            .collect::<Vec<_>>();
2344        let admitted = handles
2345            .into_iter()
2346            .map(|handle| handle.join().unwrap())
2347            .filter(|admitted| *admitted)
2348            .count();
2349
2350        assert_eq!(admitted, 1);
2351        assert_eq!(engine.policy_version(), 17);
2352    }
2353
2354    #[tokio::test]
2355    async fn omitted_dry_run_is_treated_as_actual_mutation() {
2356        let mut config = enabled_security_config();
2357        for tool_id in ["file_edit", "copy_path"] {
2358            let tool_config = ToolPolicyConfig {
2359                no_write_policy: NoWritePolicyBehavior::DryRunOnly,
2360                ..Default::default()
2361            };
2362            config.tools.insert(tool_id.to_string(), tool_config);
2363        }
2364        let engine = ToolSecurityEngine::new(config);
2365
2366        let edit = engine
2367            .check_tool_execution_with_bindings(
2368                "file_edit",
2369                &serde_json::json!({"path": "./note.txt"}),
2370                &legacy_policy_bindings("file_edit"),
2371            )
2372            .await
2373            .unwrap();
2374        assert!(edit.is_blocked());
2375
2376        let copy = engine
2377            .check_tool_execution_with_bindings(
2378                "copy_path",
2379                &serde_json::json!({
2380                    "source_path": "./source.txt",
2381                    "destination_path": "./destination.txt"
2382                }),
2383                &legacy_policy_bindings("copy_path"),
2384            )
2385            .await
2386            .unwrap();
2387        assert!(copy.is_blocked());
2388    }
2389
2390    #[tokio::test]
2391    async fn no_write_policy_dry_run_only_allows_dry_run() {
2392        let mut config = enabled_security_config();
2393        let tool_config = ToolPolicyConfig {
2394            no_write_policy: NoWritePolicyBehavior::DryRunOnly,
2395            ..Default::default()
2396        };
2397        config.tools.insert("file_edit".to_string(), tool_config);
2398        let engine = ToolSecurityEngine::new(config);
2399
2400        let dry_run = engine
2401            .check_tool_execution_with_bindings(
2402                "file_edit",
2403                &serde_json::json!({"path": "./note.txt", "dry_run": true}),
2404                &legacy_policy_bindings("file_edit"),
2405            )
2406            .await
2407            .unwrap();
2408        assert!(dry_run.is_allowed());
2409
2410        let actual = engine
2411            .check_tool_execution_with_bindings(
2412                "file_edit",
2413                &serde_json::json!({"path": "./note.txt", "dry_run": false}),
2414                &legacy_policy_bindings("file_edit"),
2415            )
2416            .await
2417            .unwrap();
2418        assert!(actual.is_blocked());
2419    }
2420}