notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

auto_accept.rs (10758B)


      1 //! Auto-accept rules for tool permission requests.
      2 //!
      3 //! This module provides a configurable rules-based system for automatically
      4 //! accepting certain tool calls without requiring user confirmation.
      5 
      6 use crate::file_update::FileUpdate;
      7 use serde_json::Value;
      8 
      9 /// A rule for auto-accepting tool calls
     10 #[derive(Debug, Clone)]
     11 pub enum AutoAcceptRule {
     12     /// Auto-accept Edit tool calls that change at most N lines
     13     SmallEdit { max_lines: usize },
     14     /// Auto-accept Bash tool calls matching these command prefixes
     15     BashCommand { prefixes: Vec<String> },
     16     /// Auto-accept specific read-only tools unconditionally
     17     ReadOnlyTool { tools: Vec<String> },
     18 }
     19 
     20 impl AutoAcceptRule {
     21     /// Check if this rule matches the given tool call
     22     fn matches(&self, tool_name: &str, tool_input: &Value) -> bool {
     23         match self {
     24             AutoAcceptRule::SmallEdit { max_lines } => {
     25                 if let Some(file_update) = FileUpdate::from_tool_call(tool_name, tool_input) {
     26                     file_update.is_small_edit(*max_lines)
     27                 } else {
     28                     false
     29                 }
     30             }
     31             AutoAcceptRule::BashCommand { prefixes } => {
     32                 if tool_name != "Bash" {
     33                     return false;
     34                 }
     35                 let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) else {
     36                     return false;
     37                 };
     38                 let command_trimmed = command.trim();
     39                 prefixes.iter().any(|prefix| {
     40                     command_trimmed.starts_with(prefix)
     41                         && (command_trimmed.len() == prefix.len()
     42                             || command_trimmed.as_bytes()[prefix.len()].is_ascii_whitespace())
     43                 })
     44             }
     45             AutoAcceptRule::ReadOnlyTool { tools } => tools.iter().any(|t| t == tool_name),
     46         }
     47     }
     48 }
     49 
     50 /// Collection of auto-accept rules
     51 #[derive(Debug, Clone)]
     52 pub struct AutoAcceptRules {
     53     rules: Vec<AutoAcceptRule>,
     54 }
     55 
     56 impl Default for AutoAcceptRules {
     57     fn default() -> Self {
     58         Self {
     59             rules: vec![
     60                 AutoAcceptRule::SmallEdit { max_lines: 2 },
     61                 AutoAcceptRule::BashCommand {
     62                     prefixes: vec![
     63                         // Cargo commands
     64                         "cargo build".into(),
     65                         "cargo check".into(),
     66                         "cargo test".into(),
     67                         "cargo fmt".into(),
     68                         "cargo clippy".into(),
     69                         "cargo run".into(),
     70                         "cargo doc".into(),
     71                         // Read-only bash commands
     72                         "grep".into(),
     73                         "rg".into(),
     74                         "find".into(),
     75                         "ls".into(),
     76                         "cat".into(),
     77                         "head".into(),
     78                         "tail".into(),
     79                         "wc".into(),
     80                         "file".into(),
     81                         "stat".into(),
     82                         "which".into(),
     83                         "type".into(),
     84                         "pwd".into(),
     85                         "tree".into(),
     86                         "du".into(),
     87                         "df".into(),
     88                         // Git read-only commands
     89                         "git status".into(),
     90                         "git log".into(),
     91                         "git diff".into(),
     92                         "git show".into(),
     93                         "git branch".into(),
     94                         "git remote".into(),
     95                         "git rev-parse".into(),
     96                         "git ls-files".into(),
     97                         "git describe".into(),
     98                         // GitHub CLI (read-only)
     99                         "gh pr view".into(),
    100                         "gh pr list".into(),
    101                         "gh pr diff".into(),
    102                         "gh pr checks".into(),
    103                         "gh pr status".into(),
    104                         "gh issue view".into(),
    105                         "gh issue list".into(),
    106                         "gh issue status".into(),
    107                         "gh repo view".into(),
    108                         "gh search".into(),
    109                         "gh release list".into(),
    110                         "gh release view".into(),
    111                         // Beads issue tracker
    112                         "bd".into(),
    113                         "beads list".into(),
    114                     ],
    115                 },
    116                 AutoAcceptRule::ReadOnlyTool {
    117                     tools: vec![
    118                         "Glob".into(),
    119                         "Grep".into(),
    120                         "Read".into(),
    121                         "WebSearch".into(),
    122                         "WebFetch".into(),
    123                     ],
    124                 },
    125             ],
    126         }
    127     }
    128 }
    129 
    130 impl AutoAcceptRules {
    131     /// Check if any rule matches the given tool call
    132     pub fn should_auto_accept(&self, tool_name: &str, tool_input: &Value) -> bool {
    133         self.rules
    134             .iter()
    135             .any(|rule| rule.matches(tool_name, tool_input))
    136     }
    137 }
    138 
    139 #[cfg(test)]
    140 mod tests {
    141     use super::*;
    142     use serde_json::json;
    143 
    144     fn default_rules() -> AutoAcceptRules {
    145         AutoAcceptRules::default()
    146     }
    147 
    148     #[test]
    149     fn test_small_edit_auto_accept() {
    150         let rules = default_rules();
    151         let input = json!({
    152             "file_path": "/path/to/file.rs",
    153             "old_string": "let x = 1;",
    154             "new_string": "let x = 2;"
    155         });
    156         assert!(rules.should_auto_accept("Edit", &input));
    157     }
    158 
    159     #[test]
    160     fn test_large_edit_not_auto_accept() {
    161         let rules = default_rules();
    162         let input = json!({
    163             "file_path": "/path/to/file.rs",
    164             "old_string": "line1\nline2\nline3\nline4",
    165             "new_string": "a\nb\nc\nd"
    166         });
    167         assert!(!rules.should_auto_accept("Edit", &input));
    168     }
    169 
    170     #[test]
    171     fn test_cargo_build_auto_accept() {
    172         let rules = default_rules();
    173         let input = json!({ "command": "cargo build" });
    174         assert!(rules.should_auto_accept("Bash", &input));
    175     }
    176 
    177     #[test]
    178     fn test_cargo_check_auto_accept() {
    179         let rules = default_rules();
    180         let input = json!({ "command": "cargo check" });
    181         assert!(rules.should_auto_accept("Bash", &input));
    182     }
    183 
    184     #[test]
    185     fn test_cargo_test_with_args_auto_accept() {
    186         let rules = default_rules();
    187         let input = json!({ "command": "cargo test --release" });
    188         assert!(rules.should_auto_accept("Bash", &input));
    189     }
    190 
    191     #[test]
    192     fn test_cargo_fmt_auto_accept() {
    193         let rules = default_rules();
    194         let input = json!({ "command": "cargo fmt" });
    195         assert!(rules.should_auto_accept("Bash", &input));
    196     }
    197 
    198     #[test]
    199     fn test_cargo_clippy_auto_accept() {
    200         let rules = default_rules();
    201         let input = json!({ "command": "cargo clippy" });
    202         assert!(rules.should_auto_accept("Bash", &input));
    203     }
    204 
    205     #[test]
    206     fn test_rm_not_auto_accept() {
    207         let rules = default_rules();
    208         let input = json!({ "command": "rm -rf /tmp/test" });
    209         assert!(!rules.should_auto_accept("Bash", &input));
    210     }
    211 
    212     #[test]
    213     fn test_curl_not_auto_accept() {
    214         let rules = default_rules();
    215         let input = json!({ "command": "curl https://example.com" });
    216         assert!(!rules.should_auto_accept("Bash", &input));
    217     }
    218 
    219     #[test]
    220     fn test_read_auto_accept() {
    221         let rules = default_rules();
    222         let input = json!({ "file_path": "/path/to/file.rs" });
    223         assert!(rules.should_auto_accept("Read", &input));
    224     }
    225 
    226     #[test]
    227     fn test_glob_auto_accept() {
    228         let rules = default_rules();
    229         let input = json!({ "pattern": "**/*.rs" });
    230         assert!(rules.should_auto_accept("Glob", &input));
    231     }
    232 
    233     #[test]
    234     fn test_grep_auto_accept() {
    235         let rules = default_rules();
    236         let input = json!({ "pattern": "TODO", "path": "/src" });
    237         assert!(rules.should_auto_accept("Grep", &input));
    238     }
    239 
    240     #[test]
    241     fn test_write_not_auto_accept() {
    242         let rules = default_rules();
    243         let input = json!({
    244             "file_path": "/path/to/file.rs",
    245             "content": "new content"
    246         });
    247         assert!(!rules.should_auto_accept("Write", &input));
    248     }
    249 
    250     #[test]
    251     fn test_unknown_tool_not_auto_accept() {
    252         let rules = default_rules();
    253         let input = json!({});
    254         assert!(!rules.should_auto_accept("UnknownTool", &input));
    255     }
    256 
    257     #[test]
    258     fn test_bash_with_leading_whitespace() {
    259         let rules = default_rules();
    260         let input = json!({ "command": "  cargo build" });
    261         assert!(rules.should_auto_accept("Bash", &input));
    262     }
    263 
    264     #[test]
    265     fn test_grep_bash_auto_accept() {
    266         let rules = default_rules();
    267         let input = json!({ "command": "grep -rn \"pattern\" /path" });
    268         assert!(rules.should_auto_accept("Bash", &input));
    269     }
    270 
    271     #[test]
    272     fn test_rg_bash_auto_accept() {
    273         let rules = default_rules();
    274         let input = json!({ "command": "rg \"pattern\" /path" });
    275         assert!(rules.should_auto_accept("Bash", &input));
    276     }
    277 
    278     #[test]
    279     fn test_find_bash_auto_accept() {
    280         let rules = default_rules();
    281         let input = json!({ "command": "find . -name \"*.rs\"" });
    282         assert!(rules.should_auto_accept("Bash", &input));
    283     }
    284 
    285     #[test]
    286     fn test_git_status_auto_accept() {
    287         let rules = default_rules();
    288         let input = json!({ "command": "git status" });
    289         assert!(rules.should_auto_accept("Bash", &input));
    290     }
    291 
    292     #[test]
    293     fn test_git_log_auto_accept() {
    294         let rules = default_rules();
    295         let input = json!({ "command": "git log --oneline -10" });
    296         assert!(rules.should_auto_accept("Bash", &input));
    297     }
    298 
    299     #[test]
    300     fn test_git_push_not_auto_accept() {
    301         let rules = default_rules();
    302         let input = json!({ "command": "git push origin main" });
    303         assert!(!rules.should_auto_accept("Bash", &input));
    304     }
    305 
    306     #[test]
    307     fn test_git_commit_not_auto_accept() {
    308         let rules = default_rules();
    309         let input = json!({ "command": "git commit -m \"test\"" });
    310         assert!(!rules.should_auto_accept("Bash", &input));
    311     }
    312 
    313     #[test]
    314     fn test_ls_auto_accept() {
    315         let rules = default_rules();
    316         let input = json!({ "command": "ls -la /tmp" });
    317         assert!(rules.should_auto_accept("Bash", &input));
    318     }
    319 
    320     #[test]
    321     fn test_cat_auto_accept() {
    322         let rules = default_rules();
    323         let input = json!({ "command": "cat /path/to/file.txt" });
    324         assert!(rules.should_auto_accept("Bash", &input));
    325     }
    326 }