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 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 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 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}