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 pub fn new(config: ToolSecurityConfig) -> Self {
55 Self::try_new(config).expect("invalid tool security configuration")
56 }
57
58 pub fn try_new(config: ToolSecurityConfig) -> Result<Self> {
60 Self::try_new_with_policy_version(config, 1)
61 }
62
63 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 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 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 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 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 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 ©_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 ©_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 ©_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}