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 }