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    struct PermTest {
134        tool: &'static str,
135        input: &'static str,
136        mode: ToolPermissionMode,
137        allow: Vec<&'static str>,
138        deny: Vec<&'static str>,
139        confirm: Vec<&'static str>,
140        global: bool,
141    }
142
143    impl PermTest {
144        fn new(input: &'static str) -> Self {
145            Self {
146                tool: "terminal",
147                input,
148                mode: ToolPermissionMode::Confirm,
149                allow: vec![],
150                deny: vec![],
151                confirm: vec![],
152                global: false,
153            }
154        }
155
156        fn tool(mut self, t: &'static str) -> Self {
157            self.tool = t;
158            self
159        }
160        fn mode(mut self, m: ToolPermissionMode) -> Self {
161            self.mode = m;
162            self
163        }
164        fn allow(mut self, p: &[&'static str]) -> Self {
165            self.allow = p.to_vec();
166            self
167        }
168        fn deny(mut self, p: &[&'static str]) -> Self {
169            self.deny = p.to_vec();
170            self
171        }
172        fn confirm(mut self, p: &[&'static str]) -> Self {
173            self.confirm = p.to_vec();
174            self
175        }
176        fn global(mut self, g: bool) -> Self {
177            self.global = g;
178            self
179        }
180
181        fn is_allow(self) {
182            assert_eq!(
183                self.run(),
184                ToolPermissionDecision::Allow,
185                "expected Allow for '{}'",
186                self.input
187            );
188        }
189        fn is_deny(self) {
190            assert!(
191                matches!(self.run(), ToolPermissionDecision::Deny(_)),
192                "expected Deny for '{}'",
193                self.input
194            );
195        }
196        fn is_confirm(self) {
197            assert_eq!(
198                self.run(),
199                ToolPermissionDecision::Confirm,
200                "expected Confirm for '{}'",
201                self.input
202            );
203        }
204
205        fn run(&self) -> ToolPermissionDecision {
206            let mut tools = collections::HashMap::default();
207            tools.insert(
208                Arc::from(self.tool),
209                ToolRules {
210                    default_mode: self.mode,
211                    always_allow: self
212                        .allow
213                        .iter()
214                        .filter_map(|p| CompiledRegex::new(p, false))
215                        .collect(),
216                    always_deny: self
217                        .deny
218                        .iter()
219                        .filter_map(|p| CompiledRegex::new(p, false))
220                        .collect(),
221                    always_confirm: self
222                        .confirm
223                        .iter()
224                        .filter_map(|p| CompiledRegex::new(p, false))
225                        .collect(),
226                    invalid_patterns: vec![],
227                },
228            );
229            decide_permission(
230                self.tool,
231                self.input,
232                &ToolPermissions { tools },
233                self.global,
234            )
235        }
236    }
237
238    fn t(input: &'static str) -> PermTest {
239        PermTest::new(input)
240    }
241
242    fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
243        decide_permission(
244            "terminal",
245            input,
246            &ToolPermissions {
247                tools: collections::HashMap::default(),
248            },
249            global,
250        )
251    }
252
253    // allow pattern matches
254    #[test]
255    fn allow_exact_match() {
256        t("cargo test").allow(&["^cargo\\s"]).is_allow();
257    }
258    #[test]
259    fn allow_with_args() {
260        t("cargo build --release").allow(&["^cargo\\s"]).is_allow();
261    }
262    #[test]
263    fn allow_one_of_many() {
264        t("npm install").allow(&["^cargo\\s", "^npm\\s"]).is_allow();
265    }
266    #[test]
267    fn allow_middle_pattern() {
268        t("run cargo now").allow(&["cargo"]).is_allow();
269    }
270    #[test]
271    fn allow_anchor_prevents_middle() {
272        t("run cargo now").allow(&["^cargo"]).is_confirm();
273    }
274
275    // allow pattern doesn't match -> falls through
276    #[test]
277    fn allow_no_match_confirms() {
278        t("python x.py").allow(&["^cargo\\s"]).is_confirm();
279    }
280    #[test]
281    fn allow_no_match_global_allows() {
282        t("python x.py")
283            .allow(&["^cargo\\s"])
284            .global(true)
285            .is_allow();
286    }
287
288    // deny pattern matches
289    #[test]
290    fn deny_blocks() {
291        t("rm -rf /").deny(&["rm\\s+-rf"]).is_deny();
292    }
293    #[test]
294    fn deny_blocks_with_global() {
295        t("rm -rf /").deny(&["rm\\s+-rf"]).global(true).is_deny();
296    }
297    #[test]
298    fn deny_blocks_with_mode_allow() {
299        t("rm -rf /")
300            .deny(&["rm\\s+-rf"])
301            .mode(ToolPermissionMode::Allow)
302            .is_deny();
303    }
304    #[test]
305    fn deny_middle_match() {
306        t("echo rm -rf x").deny(&["rm\\s+-rf"]).is_deny();
307    }
308    #[test]
309    fn deny_no_match_allows() {
310        t("ls -la").deny(&["rm\\s+-rf"]).global(true).is_allow();
311    }
312
313    // confirm pattern matches
314    #[test]
315    fn confirm_requires_confirm() {
316        t("sudo apt install").confirm(&["sudo\\s"]).is_confirm();
317    }
318    #[test]
319    fn confirm_overrides_global() {
320        t("sudo reboot")
321            .confirm(&["sudo\\s"])
322            .global(true)
323            .is_confirm();
324    }
325    #[test]
326    fn confirm_overrides_mode_allow() {
327        t("sudo x")
328            .confirm(&["sudo"])
329            .mode(ToolPermissionMode::Allow)
330            .is_confirm();
331    }
332
333    // confirm beats allow
334    #[test]
335    fn confirm_beats_allow() {
336        t("git push --force")
337            .allow(&["^git\\s"])
338            .confirm(&["--force"])
339            .is_confirm();
340    }
341    #[test]
342    fn confirm_beats_allow_overlap() {
343        t("deploy prod")
344            .allow(&["deploy"])
345            .confirm(&["prod"])
346            .is_confirm();
347    }
348    #[test]
349    fn allow_when_confirm_no_match() {
350        t("git status")
351            .allow(&["^git\\s"])
352            .confirm(&["--force"])
353            .is_allow();
354    }
355
356    // deny beats allow
357    #[test]
358    fn deny_beats_allow() {
359        t("rm -rf /tmp/x")
360            .allow(&["/tmp/"])
361            .deny(&["rm\\s+-rf"])
362            .is_deny();
363    }
364    #[test]
365    fn deny_beats_allow_diff() {
366        t("bad deploy").allow(&["deploy"]).deny(&["bad"]).is_deny();
367    }
368
369    // deny beats confirm
370    #[test]
371    fn deny_beats_confirm() {
372        t("sudo rm -rf /")
373            .confirm(&["sudo"])
374            .deny(&["rm\\s+-rf"])
375            .is_deny();
376    }
377
378    // deny beats everything
379    #[test]
380    fn deny_beats_all() {
381        t("bad cmd")
382            .allow(&["cmd"])
383            .confirm(&["cmd"])
384            .deny(&["bad"])
385            .is_deny();
386    }
387
388    // no patterns -> default_mode
389    #[test]
390    fn default_confirm() {
391        t("python x.py")
392            .mode(ToolPermissionMode::Confirm)
393            .is_confirm();
394    }
395    #[test]
396    fn default_allow() {
397        t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
398    }
399    #[test]
400    fn default_deny() {
401        t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
402    }
403
404    // default_mode confirm + global
405    #[test]
406    fn default_confirm_global_false() {
407        t("x")
408            .mode(ToolPermissionMode::Confirm)
409            .global(false)
410            .is_confirm();
411    }
412    #[test]
413    fn default_confirm_global_true() {
414        t("x")
415            .mode(ToolPermissionMode::Confirm)
416            .global(true)
417            .is_allow();
418    }
419
420    // no rules at all -> global setting
421    #[test]
422    fn no_rules_global_false() {
423        assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
424    }
425    #[test]
426    fn no_rules_global_true() {
427        assert_eq!(no_rules("x", true), ToolPermissionDecision::Allow);
428    }
429
430    // empty input
431    #[test]
432    fn empty_input_no_match() {
433        t("").deny(&["rm"]).is_confirm();
434    }
435    #[test]
436    fn empty_input_global() {
437        t("").deny(&["rm"]).global(true).is_allow();
438    }
439
440    // multiple patterns - any match
441    #[test]
442    fn multi_deny_first() {
443        t("rm x").deny(&["rm", "del", "drop"]).is_deny();
444    }
445    #[test]
446    fn multi_deny_last() {
447        t("drop x").deny(&["rm", "del", "drop"]).is_deny();
448    }
449    #[test]
450    fn multi_allow_first() {
451        t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
452    }
453    #[test]
454    fn multi_allow_last() {
455        t("git x").allow(&["^cargo", "^npm", "^git"]).is_allow();
456    }
457    #[test]
458    fn multi_none_match() {
459        t("python x")
460            .allow(&["^cargo", "^npm"])
461            .deny(&["rm"])
462            .is_confirm();
463    }
464
465    // tool isolation
466    #[test]
467    fn other_tool_not_affected() {
468        let mut tools = collections::HashMap::default();
469        tools.insert(
470            Arc::from("terminal"),
471            ToolRules {
472                default_mode: ToolPermissionMode::Deny,
473                always_allow: vec![],
474                always_deny: vec![],
475                always_confirm: vec![],
476                invalid_patterns: vec![],
477            },
478        );
479        tools.insert(
480            Arc::from("edit_file"),
481            ToolRules {
482                default_mode: ToolPermissionMode::Allow,
483                always_allow: vec![],
484                always_deny: vec![],
485                always_confirm: vec![],
486                invalid_patterns: vec![],
487            },
488        );
489        let p = ToolPermissions { tools };
490        assert!(matches!(
491            decide_permission("terminal", "x", &p, true),
492            ToolPermissionDecision::Deny(_)
493        ));
494        assert_eq!(
495            decide_permission("edit_file", "x", &p, false),
496            ToolPermissionDecision::Allow
497        );
498    }
499
500    #[test]
501    fn partial_tool_name_no_match() {
502        let mut tools = collections::HashMap::default();
503        tools.insert(
504            Arc::from("term"),
505            ToolRules {
506                default_mode: ToolPermissionMode::Deny,
507                always_allow: vec![],
508                always_deny: vec![],
509                always_confirm: vec![],
510                invalid_patterns: vec![],
511            },
512        );
513        let p = ToolPermissions { tools };
514        assert_eq!(
515            decide_permission("terminal", "x", &p, true),
516            ToolPermissionDecision::Allow
517        );
518    }
519
520    // invalid patterns block the tool
521    #[test]
522    fn invalid_pattern_blocks() {
523        let mut tools = collections::HashMap::default();
524        tools.insert(
525            Arc::from("terminal"),
526            ToolRules {
527                default_mode: ToolPermissionMode::Allow,
528                always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
529                always_deny: vec![],
530                always_confirm: vec![],
531                invalid_patterns: vec![InvalidRegexPattern {
532                    pattern: "[bad".into(),
533                    rule_type: "always_deny".into(),
534                    error: "err".into(),
535                }],
536            },
537        );
538        let p = ToolPermissions { tools };
539        assert!(matches!(
540            decide_permission("terminal", "echo hi", &p, true),
541            ToolPermissionDecision::Deny(_)
542        ));
543    }
544
545    // user scenario: only echo allowed, git should confirm
546    #[test]
547    fn user_scenario_only_echo() {
548        t("echo hello").allow(&["^echo\\s"]).is_allow();
549    }
550    #[test]
551    fn user_scenario_git_confirms() {
552        t("git status").allow(&["^echo\\s"]).is_confirm();
553    }
554    #[test]
555    fn user_scenario_rm_confirms() {
556        t("rm -rf /").allow(&["^echo\\s"]).is_confirm();
557    }
558
559    // mcp tools
560    #[test]
561    fn mcp_allow() {
562        t("")
563            .tool("mcp:fs:read")
564            .mode(ToolPermissionMode::Allow)
565            .is_allow();
566    }
567    #[test]
568    fn mcp_deny() {
569        t("")
570            .tool("mcp:bad:del")
571            .mode(ToolPermissionMode::Deny)
572            .is_deny();
573    }
574    #[test]
575    fn mcp_confirm() {
576        t("")
577            .tool("mcp:gh:issue")
578            .mode(ToolPermissionMode::Confirm)
579            .is_confirm();
580    }
581    #[test]
582    fn mcp_confirm_global() {
583        t("")
584            .tool("mcp:gh:issue")
585            .mode(ToolPermissionMode::Confirm)
586            .global(true)
587            .is_allow();
588    }
589
590    // mcp vs builtin isolation
591    #[test]
592    fn mcp_doesnt_collide_with_builtin() {
593        let mut tools = collections::HashMap::default();
594        tools.insert(
595            Arc::from("terminal"),
596            ToolRules {
597                default_mode: ToolPermissionMode::Deny,
598                always_allow: vec![],
599                always_deny: vec![],
600                always_confirm: vec![],
601                invalid_patterns: vec![],
602            },
603        );
604        tools.insert(
605            Arc::from("mcp:srv:terminal"),
606            ToolRules {
607                default_mode: ToolPermissionMode::Allow,
608                always_allow: vec![],
609                always_deny: vec![],
610                always_confirm: vec![],
611                invalid_patterns: vec![],
612            },
613        );
614        let p = ToolPermissions { tools };
615        assert!(matches!(
616            decide_permission("terminal", "x", &p, false),
617            ToolPermissionDecision::Deny(_)
618        ));
619        assert_eq!(
620            decide_permission("mcp:srv:terminal", "x", &p, false),
621            ToolPermissionDecision::Allow
622        );
623    }
624}