tool_permissions.rs

  1use agent_settings::{AgentSettings, ToolPermissions, ToolRules};
  2use settings::ToolPermissionMode;
  3
  4#[derive(Debug, Clone, PartialEq, Eq)]
  5pub enum ToolPermissionDecision {
  6    Allow,
  7    Deny(String),
  8    Confirm,
  9}
 10
 11/// Determines the permission decision for a tool invocation based on configured rules.
 12///
 13/// # Precedence Order (highest to lowest)
 14///
 15/// 1. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
 16///    This takes precedence over all other rules for security.
 17/// 2. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
 18///    the user is prompted for confirmation regardless of other settings.
 19/// 3. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
 20///    the tool call proceeds without prompting.
 21/// 4. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
 22/// 5. **`always_allow_tool_actions`** - Global setting used as fallback when no tool-specific
 23///    rules are configured, or when `default_mode` is `Confirm`.
 24///
 25/// # Pattern Matching Tips
 26///
 27/// Patterns are matched as regular expressions against the tool input (e.g., the command
 28/// string for the terminal tool). Some tips for writing effective patterns:
 29///
 30/// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
 31///   match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
 32///   This is important for security rules where you want to block specific commands
 33///   without accidentally blocking unrelated commands that happen to contain the same
 34///   substring.
 35/// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
 36/// - Use `^` and `$` anchors to match the start/end of the input.
 37pub fn decide_permission(
 38    tool_name: &str,
 39    input: &str,
 40    permissions: &ToolPermissions,
 41    always_allow_tool_actions: bool,
 42) -> ToolPermissionDecision {
 43    let rules = permissions.tools.get(tool_name);
 44
 45    let rules = match rules {
 46        Some(rules) => rules,
 47        None => {
 48            return if always_allow_tool_actions {
 49                ToolPermissionDecision::Allow
 50            } else {
 51                ToolPermissionDecision::Confirm
 52            };
 53        }
 54    };
 55
 56    // Check for invalid regex patterns before evaluating rules.
 57    // If any patterns failed to compile, block the tool call entirely.
 58    if let Some(error) = check_invalid_patterns(tool_name, rules) {
 59        return ToolPermissionDecision::Deny(error);
 60    }
 61
 62    if rules.always_deny.iter().any(|r| r.is_match(input)) {
 63        return ToolPermissionDecision::Deny(format!(
 64            "Command blocked by security rule for {} tool",
 65            tool_name
 66        ));
 67    }
 68
 69    if rules.always_confirm.iter().any(|r| r.is_match(input)) {
 70        return ToolPermissionDecision::Confirm;
 71    }
 72
 73    if rules.always_allow.iter().any(|r| r.is_match(input)) {
 74        return ToolPermissionDecision::Allow;
 75    }
 76
 77    match rules.default_mode {
 78        ToolPermissionMode::Deny => {
 79            ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
 80        }
 81        ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
 82        ToolPermissionMode::Confirm => {
 83            if always_allow_tool_actions {
 84                ToolPermissionDecision::Allow
 85            } else {
 86                ToolPermissionDecision::Confirm
 87            }
 88        }
 89    }
 90}
 91
 92/// Checks if the tool rules contain any invalid regex patterns.
 93/// Returns an error message if invalid patterns are found.
 94fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
 95    if rules.invalid_patterns.is_empty() {
 96        return None;
 97    }
 98
 99    let count = rules.invalid_patterns.len();
100    let pattern_word = if count == 1 { "pattern" } else { "patterns" };
101
102    Some(format!(
103        "The {} tool cannot run because {} regex {} failed to compile. \
104         Please fix the invalid patterns in your tool_permissions settings.",
105        tool_name, count, pattern_word
106    ))
107}
108
109/// Convenience wrapper that extracts permission settings from `AgentSettings`.
110///
111/// This is the primary entry point for tools to check permissions. It extracts
112/// `tool_permissions` and `always_allow_tool_actions` from the settings and
113/// delegates to [`decide_permission`].
114pub fn decide_permission_from_settings(
115    tool_name: &str,
116    input: &str,
117    settings: &AgentSettings,
118) -> ToolPermissionDecision {
119    decide_permission(
120        tool_name,
121        input,
122        &settings.tool_permissions,
123        settings.always_allow_tool_actions,
124    )
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
131    use std::sync::Arc;
132
133    fn empty_permissions() -> ToolPermissions {
134        ToolPermissions {
135            tools: collections::HashMap::default(),
136        }
137    }
138
139    fn terminal_rules_with_deny(patterns: &[&str]) -> ToolPermissions {
140        let mut tools = collections::HashMap::default();
141        tools.insert(
142            Arc::from("terminal"),
143            ToolRules {
144                default_mode: ToolPermissionMode::Confirm,
145                always_allow: vec![],
146                always_deny: patterns
147                    .iter()
148                    .filter_map(|p| CompiledRegex::new(p, false))
149                    .collect(),
150                always_confirm: vec![],
151                invalid_patterns: vec![],
152            },
153        );
154        ToolPermissions { tools }
155    }
156
157    fn terminal_rules_with_allow(patterns: &[&str]) -> ToolPermissions {
158        let mut tools = collections::HashMap::default();
159        tools.insert(
160            Arc::from("terminal"),
161            ToolRules {
162                default_mode: ToolPermissionMode::Confirm,
163                always_allow: patterns
164                    .iter()
165                    .filter_map(|p| CompiledRegex::new(p, false))
166                    .collect(),
167                always_deny: vec![],
168                always_confirm: vec![],
169                invalid_patterns: vec![],
170            },
171        );
172        ToolPermissions { tools }
173    }
174
175    #[test]
176    fn test_deny_takes_precedence_over_allow() {
177        let mut tools = collections::HashMap::default();
178        tools.insert(
179            Arc::from("terminal"),
180            ToolRules {
181                default_mode: ToolPermissionMode::Allow,
182                always_allow: vec![CompiledRegex::new("dangerous", false).unwrap()],
183                always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
184                always_confirm: vec![],
185                invalid_patterns: vec![],
186            },
187        );
188        let permissions = ToolPermissions { tools };
189
190        let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
191        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
192    }
193
194    #[test]
195    fn test_deny_takes_precedence_over_confirm() {
196        let mut tools = collections::HashMap::default();
197        tools.insert(
198            Arc::from("terminal"),
199            ToolRules {
200                default_mode: ToolPermissionMode::Allow,
201                always_allow: vec![],
202                always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
203                always_confirm: vec![CompiledRegex::new("dangerous", false).unwrap()],
204                invalid_patterns: vec![],
205            },
206        );
207        let permissions = ToolPermissions { tools };
208
209        let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
210        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
211    }
212
213    #[test]
214    fn test_confirm_takes_precedence_over_allow() {
215        let mut tools = collections::HashMap::default();
216        tools.insert(
217            Arc::from("terminal"),
218            ToolRules {
219                default_mode: ToolPermissionMode::Allow,
220                always_allow: vec![CompiledRegex::new("risky", false).unwrap()],
221                always_deny: vec![],
222                always_confirm: vec![CompiledRegex::new("risky", false).unwrap()],
223                invalid_patterns: vec![],
224            },
225        );
226        let permissions = ToolPermissions { tools };
227
228        let decision = decide_permission("terminal", "do risky thing", &permissions, true);
229        assert_eq!(decision, ToolPermissionDecision::Confirm);
230    }
231
232    #[test]
233    fn test_no_tool_rules_uses_global_setting() {
234        let permissions = empty_permissions();
235
236        let decision = decide_permission("terminal", "any command", &permissions, false);
237        assert_eq!(decision, ToolPermissionDecision::Confirm);
238
239        let decision = decide_permission("terminal", "any command", &permissions, true);
240        assert_eq!(decision, ToolPermissionDecision::Allow);
241    }
242
243    #[test]
244    fn test_default_mode_fallthrough() {
245        // default_mode: Allow - should allow regardless of global setting
246        let mut tools = collections::HashMap::default();
247        tools.insert(
248            Arc::from("terminal"),
249            ToolRules {
250                default_mode: ToolPermissionMode::Allow,
251                always_allow: vec![],
252                always_deny: vec![],
253                always_confirm: vec![],
254                invalid_patterns: vec![],
255            },
256        );
257        let permissions = ToolPermissions { tools };
258        let decision = decide_permission("terminal", "any command", &permissions, false);
259        assert_eq!(decision, ToolPermissionDecision::Allow);
260
261        // default_mode: Deny - should deny regardless of global setting
262        let mut tools = collections::HashMap::default();
263        tools.insert(
264            Arc::from("terminal"),
265            ToolRules {
266                default_mode: ToolPermissionMode::Deny,
267                always_allow: vec![],
268                always_deny: vec![],
269                always_confirm: vec![],
270                invalid_patterns: vec![],
271            },
272        );
273        let permissions = ToolPermissions { tools };
274        let decision = decide_permission("terminal", "any command", &permissions, true);
275        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
276
277        // default_mode: Confirm - respects global always_allow_tool_actions
278        let mut tools = collections::HashMap::default();
279        tools.insert(
280            Arc::from("terminal"),
281            ToolRules {
282                default_mode: ToolPermissionMode::Confirm,
283                always_allow: vec![],
284                always_deny: vec![],
285                always_confirm: vec![],
286                invalid_patterns: vec![],
287            },
288        );
289        let permissions = ToolPermissions { tools };
290        let decision = decide_permission("terminal", "any command", &permissions, false);
291        assert_eq!(decision, ToolPermissionDecision::Confirm);
292        let decision = decide_permission("terminal", "any command", &permissions, true);
293        assert_eq!(decision, ToolPermissionDecision::Allow);
294    }
295
296    #[test]
297    fn test_empty_input() {
298        let permissions = terminal_rules_with_deny(&["rm"]);
299
300        // Empty input doesn't match the deny pattern, so falls through to default_mode (Confirm)
301        let decision = decide_permission("terminal", "", &permissions, false);
302        assert_eq!(decision, ToolPermissionDecision::Confirm);
303
304        // With always_allow_tool_actions=true and default_mode=Confirm, it returns Allow
305        let decision = decide_permission("terminal", "", &permissions, true);
306        assert_eq!(decision, ToolPermissionDecision::Allow);
307    }
308
309    #[test]
310    fn test_multiple_patterns_any_match() {
311        // Multiple deny patterns - any match should deny
312        let permissions = terminal_rules_with_deny(&["rm", "dangerous", "delete"]);
313
314        let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
315        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
316
317        let decision = decide_permission("terminal", "delete file", &permissions, true);
318        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
319
320        // Multiple allow patterns - any match should allow
321        let permissions = terminal_rules_with_allow(&["^cargo", "^npm", "^git"]);
322
323        let decision = decide_permission("terminal", "cargo build", &permissions, false);
324        assert_eq!(decision, ToolPermissionDecision::Allow);
325
326        let decision = decide_permission("terminal", "npm install", &permissions, false);
327        assert_eq!(decision, ToolPermissionDecision::Allow);
328
329        // No pattern matches - falls through to default
330        let decision = decide_permission("terminal", "rm file", &permissions, false);
331        assert_eq!(decision, ToolPermissionDecision::Confirm);
332    }
333
334    #[test]
335    fn test_case_insensitive_matching() {
336        // Case-insensitive by default (case_sensitive: false)
337        let mut tools = collections::HashMap::default();
338        tools.insert(
339            Arc::from("terminal"),
340            ToolRules {
341                default_mode: ToolPermissionMode::Confirm,
342                always_allow: vec![],
343                always_deny: vec![CompiledRegex::new(r"\brm\b", false).unwrap()],
344                always_confirm: vec![],
345                invalid_patterns: vec![],
346            },
347        );
348        let permissions = ToolPermissions { tools };
349
350        // Should match regardless of case
351        let decision = decide_permission("terminal", "RM file.txt", &permissions, true);
352        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
353
354        let decision = decide_permission("terminal", "Rm file.txt", &permissions, true);
355        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
356
357        let decision = decide_permission("terminal", "rm file.txt", &permissions, true);
358        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
359    }
360
361    #[test]
362    fn test_case_sensitive_matching() {
363        // Case-sensitive matching when explicitly enabled
364        let mut tools = collections::HashMap::default();
365        tools.insert(
366            Arc::from("terminal"),
367            ToolRules {
368                default_mode: ToolPermissionMode::Confirm,
369                always_allow: vec![],
370                always_deny: vec![CompiledRegex::new("DROP TABLE", true).unwrap()],
371                always_confirm: vec![],
372                invalid_patterns: vec![],
373            },
374        );
375        let permissions = ToolPermissions { tools };
376
377        // Should only match exact case
378        let decision = decide_permission("terminal", "DROP TABLE users", &permissions, true);
379        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
380
381        // Should NOT match different case
382        let decision = decide_permission("terminal", "drop table users", &permissions, true);
383        assert_eq!(decision, ToolPermissionDecision::Allow);
384    }
385
386    #[test]
387    fn test_multi_tool_isolation() {
388        // Rules for terminal should not affect edit_file
389        let mut tools = collections::HashMap::default();
390        tools.insert(
391            Arc::from("terminal"),
392            ToolRules {
393                default_mode: ToolPermissionMode::Deny,
394                always_allow: vec![],
395                always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
396                always_confirm: vec![],
397                invalid_patterns: vec![],
398            },
399        );
400        tools.insert(
401            Arc::from("edit_file"),
402            ToolRules {
403                default_mode: ToolPermissionMode::Allow,
404                always_allow: vec![],
405                always_deny: vec![],
406                always_confirm: vec![],
407                invalid_patterns: vec![],
408            },
409        );
410        let permissions = ToolPermissions { tools };
411
412        // Terminal with "dangerous" should be denied
413        let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
414        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
415
416        // edit_file with "dangerous" should be allowed (no deny rules for edit_file)
417        let decision = decide_permission("edit_file", "dangerous_file.txt", &permissions, true);
418        assert_eq!(decision, ToolPermissionDecision::Allow);
419
420        // Terminal without "dangerous" should still be denied due to default_mode: Deny
421        let decision = decide_permission("terminal", "safe command", &permissions, true);
422        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
423    }
424
425    #[test]
426    fn test_invalid_patterns_block_tool() {
427        let mut tools = collections::HashMap::default();
428        tools.insert(
429            Arc::from("terminal"),
430            ToolRules {
431                default_mode: ToolPermissionMode::Allow,
432                always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
433                always_deny: vec![],
434                always_confirm: vec![],
435                invalid_patterns: vec![InvalidRegexPattern {
436                    pattern: "[invalid(regex".to_string(),
437                    rule_type: "always_deny".to_string(),
438                    error: "unclosed character class".to_string(),
439                }],
440            },
441        );
442        let permissions = ToolPermissions { tools };
443
444        // Even though "echo" matches always_allow, the tool should be blocked
445        // because there are invalid patterns
446        let decision = decide_permission("terminal", "echo hello", &permissions, true);
447        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
448
449        if let ToolPermissionDecision::Deny(msg) = decision {
450            assert!(
451                msg.contains("regex"),
452                "error message should mention regex: {}",
453                msg
454            );
455            assert!(
456                msg.contains("settings"),
457                "error message should mention settings: {}",
458                msg
459            );
460            assert!(
461                msg.contains("terminal"),
462                "error message should mention the tool name: {}",
463                msg
464            );
465        }
466    }
467
468    #[test]
469    fn test_same_pattern_in_deny_and_allow_deny_wins() {
470        // When the same pattern appears in both deny and allow lists, deny should win
471        let mut tools = collections::HashMap::default();
472        tools.insert(
473            Arc::from("terminal"),
474            ToolRules {
475                default_mode: ToolPermissionMode::Allow,
476                always_allow: vec![CompiledRegex::new("deploy", false).unwrap()],
477                always_deny: vec![CompiledRegex::new("deploy", false).unwrap()],
478                always_confirm: vec![],
479                invalid_patterns: vec![],
480            },
481        );
482        let permissions = ToolPermissions { tools };
483
484        let decision = decide_permission("terminal", "deploy production", &permissions, true);
485        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
486    }
487
488    #[test]
489    fn test_same_pattern_in_confirm_and_allow_confirm_wins() {
490        // When the same pattern appears in both confirm and allow lists, confirm should win
491        let mut tools = collections::HashMap::default();
492        tools.insert(
493            Arc::from("terminal"),
494            ToolRules {
495                default_mode: ToolPermissionMode::Allow,
496                always_allow: vec![CompiledRegex::new("deploy", false).unwrap()],
497                always_deny: vec![],
498                always_confirm: vec![CompiledRegex::new("deploy", false).unwrap()],
499                invalid_patterns: vec![],
500            },
501        );
502        let permissions = ToolPermissions { tools };
503
504        let decision = decide_permission("terminal", "deploy production", &permissions, true);
505        assert_eq!(decision, ToolPermissionDecision::Confirm);
506    }
507
508    #[test]
509    fn test_partial_tool_name_does_not_match() {
510        // Rules for "term" should not affect "terminal"
511        let mut tools = collections::HashMap::default();
512        tools.insert(
513            Arc::from("term"),
514            ToolRules {
515                default_mode: ToolPermissionMode::Deny,
516                always_allow: vec![],
517                always_deny: vec![],
518                always_confirm: vec![],
519                invalid_patterns: vec![],
520            },
521        );
522        let permissions = ToolPermissions { tools };
523
524        // "terminal" should not be affected by "term" rules, falls back to global setting
525        let decision = decide_permission("terminal", "echo hello", &permissions, true);
526        assert_eq!(decision, ToolPermissionDecision::Allow);
527
528        let decision = decide_permission("terminal", "echo hello", &permissions, false);
529        assert_eq!(decision, ToolPermissionDecision::Confirm);
530    }
531
532    #[test]
533    fn test_very_long_input() {
534        // Test that very long inputs are handled correctly
535        let permissions = terminal_rules_with_deny(&[r"\brm\b"]);
536
537        // Long input without the pattern should not match
538        let long_safe_input = "echo ".to_string() + &"a".repeat(100_000);
539        let decision = decide_permission("terminal", &long_safe_input, &permissions, true);
540        assert_eq!(decision, ToolPermissionDecision::Allow);
541
542        // Long input with the pattern should match
543        let long_dangerous_input = "a".repeat(50_000) + " rm " + &"b".repeat(50_000);
544        let decision = decide_permission("terminal", &long_dangerous_input, &permissions, true);
545        assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
546    }
547}