Skip to main content

ai_agents_tools/security/
engine.rs

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