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_allow_tool_actions`** - When enabled, allows all tool actions except those
 16///    blocked by `always_deny` patterns. This global setting takes precedence over
 17///    `always_confirm` patterns and `default_mode`.
 18/// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
 19///    This takes precedence over all other rules for security (including `always_allow_tool_actions`).
 20/// 3. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
 21///    the user is prompted for confirmation (unless `always_allow_tool_actions` is enabled).
 22/// 4. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
 23///    the tool call proceeds without prompting.
 24/// 5. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
 25///
 26/// # Pattern Matching Tips
 27///
 28/// Patterns are matched as regular expressions against the tool input (e.g., the command
 29/// string for the terminal tool). Some tips for writing effective patterns:
 30///
 31/// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
 32///   match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
 33///   This is important for security rules where you want to block specific commands
 34///   without accidentally blocking unrelated commands that happen to contain the same
 35///   substring.
 36/// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
 37/// - Use `^` and `$` anchors to match the start/end of the input.
 38pub fn decide_permission(
 39    tool_name: &str,
 40    input: &str,
 41    permissions: &ToolPermissions,
 42    always_allow_tool_actions: bool,
 43) -> ToolPermissionDecision {
 44    let rules = permissions.tools.get(tool_name);
 45
 46    let rules = match rules {
 47        Some(rules) => rules,
 48        None => {
 49            return if always_allow_tool_actions {
 50                ToolPermissionDecision::Allow
 51            } else {
 52                ToolPermissionDecision::Confirm
 53            };
 54        }
 55    };
 56
 57    // Check for invalid regex patterns before evaluating rules.
 58    // If any patterns failed to compile, block the tool call entirely.
 59    if let Some(error) = check_invalid_patterns(tool_name, rules) {
 60        return ToolPermissionDecision::Deny(error);
 61    }
 62
 63    if rules.always_deny.iter().any(|r| r.is_match(input)) {
 64        return ToolPermissionDecision::Deny(format!(
 65            "Command blocked by security rule for {} tool",
 66            tool_name
 67        ));
 68    }
 69
 70    if rules.always_confirm.iter().any(|r| r.is_match(input)) {
 71        if !always_allow_tool_actions {
 72            return ToolPermissionDecision::Confirm;
 73        }
 74    }
 75
 76    if rules.always_allow.iter().any(|r| r.is_match(input)) {
 77        return ToolPermissionDecision::Allow;
 78    }
 79
 80    if always_allow_tool_actions {
 81        return ToolPermissionDecision::Allow;
 82    }
 83
 84    match rules.default_mode {
 85        ToolPermissionMode::Deny => {
 86            ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
 87        }
 88        ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
 89        ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
 90    }
 91}
 92
 93/// Checks if the tool rules contain any invalid regex patterns.
 94/// Returns an error message if invalid patterns are found.
 95fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
 96    if rules.invalid_patterns.is_empty() {
 97        return None;
 98    }
 99
100    let count = rules.invalid_patterns.len();
101    let pattern_word = if count == 1 { "pattern" } else { "patterns" };
102
103    Some(format!(
104        "The {} tool cannot run because {} regex {} failed to compile. \
105         Please fix the invalid patterns in your tool_permissions settings.",
106        tool_name, count, pattern_word
107    ))
108}
109
110/// Convenience wrapper that extracts permission settings from `AgentSettings`.
111///
112/// This is the primary entry point for tools to check permissions. It extracts
113/// `tool_permissions` and `always_allow_tool_actions` from the settings and
114/// delegates to [`decide_permission`].
115pub fn decide_permission_from_settings(
116    tool_name: &str,
117    input: &str,
118    settings: &AgentSettings,
119) -> ToolPermissionDecision {
120    decide_permission(
121        tool_name,
122        input,
123        &settings.tool_permissions,
124        settings.always_allow_tool_actions,
125    )
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
132    use std::sync::Arc;
133
134    struct PermTest {
135        tool: &'static str,
136        input: &'static str,
137        mode: ToolPermissionMode,
138        allow: Vec<&'static str>,
139        deny: Vec<&'static str>,
140        confirm: Vec<&'static str>,
141        global: bool,
142    }
143
144    impl PermTest {
145        fn new(input: &'static str) -> Self {
146            Self {
147                tool: "terminal",
148                input,
149                mode: ToolPermissionMode::Confirm,
150                allow: vec![],
151                deny: vec![],
152                confirm: vec![],
153                global: false,
154            }
155        }
156
157        fn tool(mut self, t: &'static str) -> Self {
158            self.tool = t;
159            self
160        }
161        fn mode(mut self, m: ToolPermissionMode) -> Self {
162            self.mode = m;
163            self
164        }
165        fn allow(mut self, p: &[&'static str]) -> Self {
166            self.allow = p.to_vec();
167            self
168        }
169        fn deny(mut self, p: &[&'static str]) -> Self {
170            self.deny = p.to_vec();
171            self
172        }
173        fn confirm(mut self, p: &[&'static str]) -> Self {
174            self.confirm = p.to_vec();
175            self
176        }
177        fn global(mut self, g: bool) -> Self {
178            self.global = g;
179            self
180        }
181
182        fn is_allow(self) {
183            assert_eq!(
184                self.run(),
185                ToolPermissionDecision::Allow,
186                "expected Allow for '{}'",
187                self.input
188            );
189        }
190        fn is_deny(self) {
191            assert!(
192                matches!(self.run(), ToolPermissionDecision::Deny(_)),
193                "expected Deny for '{}'",
194                self.input
195            );
196        }
197        fn is_confirm(self) {
198            assert_eq!(
199                self.run(),
200                ToolPermissionDecision::Confirm,
201                "expected Confirm for '{}'",
202                self.input
203            );
204        }
205
206        fn run(&self) -> ToolPermissionDecision {
207            let mut tools = collections::HashMap::default();
208            tools.insert(
209                Arc::from(self.tool),
210                ToolRules {
211                    default_mode: self.mode,
212                    always_allow: self
213                        .allow
214                        .iter()
215                        .filter_map(|p| CompiledRegex::new(p, false))
216                        .collect(),
217                    always_deny: self
218                        .deny
219                        .iter()
220                        .filter_map(|p| CompiledRegex::new(p, false))
221                        .collect(),
222                    always_confirm: self
223                        .confirm
224                        .iter()
225                        .filter_map(|p| CompiledRegex::new(p, false))
226                        .collect(),
227                    invalid_patterns: vec![],
228                },
229            );
230            decide_permission(
231                self.tool,
232                self.input,
233                &ToolPermissions { tools },
234                self.global,
235            )
236        }
237    }
238
239    fn t(input: &'static str) -> PermTest {
240        PermTest::new(input)
241    }
242
243    fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
244        decide_permission(
245            "terminal",
246            input,
247            &ToolPermissions {
248                tools: collections::HashMap::default(),
249            },
250            global,
251        )
252    }
253
254    // allow pattern matches
255    #[test]
256    fn allow_exact_match() {
257        t("cargo test").allow(&["^cargo\\s"]).is_allow();
258    }
259    #[test]
260    fn allow_with_args() {
261        t("cargo build --release").allow(&["^cargo\\s"]).is_allow();
262    }
263    #[test]
264    fn allow_one_of_many() {
265        t("npm install").allow(&["^cargo\\s", "^npm\\s"]).is_allow();
266    }
267    #[test]
268    fn allow_middle_pattern() {
269        t("run cargo now").allow(&["cargo"]).is_allow();
270    }
271    #[test]
272    fn allow_anchor_prevents_middle() {
273        t("run cargo now").allow(&["^cargo"]).is_confirm();
274    }
275
276    // allow pattern doesn't match -> falls through
277    #[test]
278    fn allow_no_match_confirms() {
279        t("python x.py").allow(&["^cargo\\s"]).is_confirm();
280    }
281    #[test]
282    fn allow_no_match_global_allows() {
283        t("python x.py")
284            .allow(&["^cargo\\s"])
285            .global(true)
286            .is_allow();
287    }
288
289    // deny pattern matches
290    #[test]
291    fn deny_blocks() {
292        t("rm -rf /").deny(&["rm\\s+-rf"]).is_deny();
293    }
294    #[test]
295    fn deny_blocks_with_global() {
296        t("rm -rf /").deny(&["rm\\s+-rf"]).global(true).is_deny();
297    }
298    #[test]
299    fn deny_blocks_with_mode_allow() {
300        t("rm -rf /")
301            .deny(&["rm\\s+-rf"])
302            .mode(ToolPermissionMode::Allow)
303            .is_deny();
304    }
305    #[test]
306    fn deny_middle_match() {
307        t("echo rm -rf x").deny(&["rm\\s+-rf"]).is_deny();
308    }
309    #[test]
310    fn deny_no_match_allows() {
311        t("ls -la").deny(&["rm\\s+-rf"]).global(true).is_allow();
312    }
313
314    // confirm pattern matches
315    #[test]
316    fn confirm_requires_confirm() {
317        t("sudo apt install").confirm(&["sudo\\s"]).is_confirm();
318    }
319    #[test]
320    fn global_overrides_confirm() {
321        t("sudo reboot")
322            .confirm(&["sudo\\s"])
323            .global(true)
324            .is_allow();
325    }
326    #[test]
327    fn confirm_overrides_mode_allow() {
328        t("sudo x")
329            .confirm(&["sudo"])
330            .mode(ToolPermissionMode::Allow)
331            .is_confirm();
332    }
333
334    // confirm beats allow
335    #[test]
336    fn confirm_beats_allow() {
337        t("git push --force")
338            .allow(&["^git\\s"])
339            .confirm(&["--force"])
340            .is_confirm();
341    }
342    #[test]
343    fn confirm_beats_allow_overlap() {
344        t("deploy prod")
345            .allow(&["deploy"])
346            .confirm(&["prod"])
347            .is_confirm();
348    }
349    #[test]
350    fn allow_when_confirm_no_match() {
351        t("git status")
352            .allow(&["^git\\s"])
353            .confirm(&["--force"])
354            .is_allow();
355    }
356
357    // deny beats allow
358    #[test]
359    fn deny_beats_allow() {
360        t("rm -rf /tmp/x")
361            .allow(&["/tmp/"])
362            .deny(&["rm\\s+-rf"])
363            .is_deny();
364    }
365    #[test]
366    fn deny_beats_allow_diff() {
367        t("bad deploy").allow(&["deploy"]).deny(&["bad"]).is_deny();
368    }
369
370    // deny beats confirm
371    #[test]
372    fn deny_beats_confirm() {
373        t("sudo rm -rf /")
374            .confirm(&["sudo"])
375            .deny(&["rm\\s+-rf"])
376            .is_deny();
377    }
378
379    // deny beats everything
380    #[test]
381    fn deny_beats_all() {
382        t("bad cmd")
383            .allow(&["cmd"])
384            .confirm(&["cmd"])
385            .deny(&["bad"])
386            .is_deny();
387    }
388
389    // no patterns -> default_mode
390    #[test]
391    fn default_confirm() {
392        t("python x.py")
393            .mode(ToolPermissionMode::Confirm)
394            .is_confirm();
395    }
396    #[test]
397    fn default_allow() {
398        t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
399    }
400    #[test]
401    fn default_deny() {
402        t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
403    }
404    #[test]
405    fn default_deny_global_true() {
406        t("python x.py")
407            .mode(ToolPermissionMode::Deny)
408            .global(true)
409            .is_allow();
410    }
411
412    // default_mode confirm + global
413    #[test]
414    fn default_confirm_global_false() {
415        t("x")
416            .mode(ToolPermissionMode::Confirm)
417            .global(false)
418            .is_confirm();
419    }
420    #[test]
421    fn default_confirm_global_true() {
422        t("x")
423            .mode(ToolPermissionMode::Confirm)
424            .global(true)
425            .is_allow();
426    }
427
428    // no rules at all -> global setting
429    #[test]
430    fn no_rules_global_false() {
431        assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
432    }
433    #[test]
434    fn no_rules_global_true() {
435        assert_eq!(no_rules("x", true), ToolPermissionDecision::Allow);
436    }
437
438    // empty input
439    #[test]
440    fn empty_input_no_match() {
441        t("").deny(&["rm"]).is_confirm();
442    }
443    #[test]
444    fn empty_input_global() {
445        t("").deny(&["rm"]).global(true).is_allow();
446    }
447
448    // multiple patterns - any match
449    #[test]
450    fn multi_deny_first() {
451        t("rm x").deny(&["rm", "del", "drop"]).is_deny();
452    }
453    #[test]
454    fn multi_deny_last() {
455        t("drop x").deny(&["rm", "del", "drop"]).is_deny();
456    }
457    #[test]
458    fn multi_allow_first() {
459        t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
460    }
461    #[test]
462    fn multi_allow_last() {
463        t("git x").allow(&["^cargo", "^npm", "^git"]).is_allow();
464    }
465    #[test]
466    fn multi_none_match() {
467        t("python x")
468            .allow(&["^cargo", "^npm"])
469            .deny(&["rm"])
470            .is_confirm();
471    }
472
473    // tool isolation
474    #[test]
475    fn other_tool_not_affected() {
476        let mut tools = collections::HashMap::default();
477        tools.insert(
478            Arc::from("terminal"),
479            ToolRules {
480                default_mode: ToolPermissionMode::Deny,
481                always_allow: vec![],
482                always_deny: vec![],
483                always_confirm: vec![],
484                invalid_patterns: vec![],
485            },
486        );
487        tools.insert(
488            Arc::from("edit_file"),
489            ToolRules {
490                default_mode: ToolPermissionMode::Allow,
491                always_allow: vec![],
492                always_deny: vec![],
493                always_confirm: vec![],
494                invalid_patterns: vec![],
495            },
496        );
497        let p = ToolPermissions { tools };
498        // With always_allow_tool_actions=true, even default_mode: Deny is overridden
499        assert_eq!(
500            decide_permission("terminal", "x", &p, true),
501            ToolPermissionDecision::Allow
502        );
503        // With always_allow_tool_actions=false, default_mode: Deny is respected
504        assert!(matches!(
505            decide_permission("terminal", "x", &p, false),
506            ToolPermissionDecision::Deny(_)
507        ));
508        assert_eq!(
509            decide_permission("edit_file", "x", &p, false),
510            ToolPermissionDecision::Allow
511        );
512    }
513
514    #[test]
515    fn partial_tool_name_no_match() {
516        let mut tools = collections::HashMap::default();
517        tools.insert(
518            Arc::from("term"),
519            ToolRules {
520                default_mode: ToolPermissionMode::Deny,
521                always_allow: vec![],
522                always_deny: vec![],
523                always_confirm: vec![],
524                invalid_patterns: vec![],
525            },
526        );
527        let p = ToolPermissions { tools };
528        assert_eq!(
529            decide_permission("terminal", "x", &p, true),
530            ToolPermissionDecision::Allow
531        );
532    }
533
534    // invalid patterns block the tool
535    #[test]
536    fn invalid_pattern_blocks() {
537        let mut tools = collections::HashMap::default();
538        tools.insert(
539            Arc::from("terminal"),
540            ToolRules {
541                default_mode: ToolPermissionMode::Allow,
542                always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
543                always_deny: vec![],
544                always_confirm: vec![],
545                invalid_patterns: vec![InvalidRegexPattern {
546                    pattern: "[bad".into(),
547                    rule_type: "always_deny".into(),
548                    error: "err".into(),
549                }],
550            },
551        );
552        let p = ToolPermissions { tools };
553        assert!(matches!(
554            decide_permission("terminal", "echo hi", &p, true),
555            ToolPermissionDecision::Deny(_)
556        ));
557    }
558
559    // user scenario: only echo allowed, git should confirm
560    #[test]
561    fn user_scenario_only_echo() {
562        t("echo hello").allow(&["^echo\\s"]).is_allow();
563    }
564    #[test]
565    fn user_scenario_git_confirms() {
566        t("git status").allow(&["^echo\\s"]).is_confirm();
567    }
568    #[test]
569    fn user_scenario_rm_confirms() {
570        t("rm -rf /").allow(&["^echo\\s"]).is_confirm();
571    }
572
573    // mcp tools
574    #[test]
575    fn mcp_allow() {
576        t("")
577            .tool("mcp:fs:read")
578            .mode(ToolPermissionMode::Allow)
579            .is_allow();
580    }
581    #[test]
582    fn mcp_deny() {
583        t("")
584            .tool("mcp:bad:del")
585            .mode(ToolPermissionMode::Deny)
586            .is_deny();
587    }
588    #[test]
589    fn mcp_confirm() {
590        t("")
591            .tool("mcp:gh:issue")
592            .mode(ToolPermissionMode::Confirm)
593            .is_confirm();
594    }
595    #[test]
596    fn mcp_confirm_global() {
597        t("")
598            .tool("mcp:gh:issue")
599            .mode(ToolPermissionMode::Confirm)
600            .global(true)
601            .is_allow();
602    }
603
604    // mcp vs builtin isolation
605    #[test]
606    fn mcp_doesnt_collide_with_builtin() {
607        let mut tools = collections::HashMap::default();
608        tools.insert(
609            Arc::from("terminal"),
610            ToolRules {
611                default_mode: ToolPermissionMode::Deny,
612                always_allow: vec![],
613                always_deny: vec![],
614                always_confirm: vec![],
615                invalid_patterns: vec![],
616            },
617        );
618        tools.insert(
619            Arc::from("mcp:srv:terminal"),
620            ToolRules {
621                default_mode: ToolPermissionMode::Allow,
622                always_allow: vec![],
623                always_deny: vec![],
624                always_confirm: vec![],
625                invalid_patterns: vec![],
626            },
627        );
628        let p = ToolPermissions { tools };
629        assert!(matches!(
630            decide_permission("terminal", "x", &p, false),
631            ToolPermissionDecision::Deny(_)
632        ));
633        assert_eq!(
634            decide_permission("mcp:srv:terminal", "x", &p, false),
635            ToolPermissionDecision::Allow
636        );
637    }
638}