notedeck

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

commit 6d321a460071b73c726839e593978b511e3970dc
parent 0d211ed0e885c5d5cdeea797ebf7ca5153fb7a49
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 30 Jan 2026 22:37:31 -0800

feat(dave): add rules-based auto-accept system for permissions

Replace hardcoded small-edit auto-accept with configurable rules system.
Now auto-accepts cargo commands (build, check, test, fmt, clippy) and
read-only tools (Glob, Grep, Read) in addition to small edits (≤2 lines).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Acrates/notedeck_dave/src/auto_accept.rs | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/backend/claude.rs | 16+++++++---------
Mcrates/notedeck_dave/src/lib.rs | 1+
3 files changed, 217 insertions(+), 9 deletions(-)

diff --git a/crates/notedeck_dave/src/auto_accept.rs b/crates/notedeck_dave/src/auto_accept.rs @@ -0,0 +1,209 @@ +//! Auto-accept rules for tool permission requests. +//! +//! This module provides a configurable rules-based system for automatically +//! accepting certain tool calls without requiring user confirmation. + +use crate::file_update::FileUpdate; +use serde_json::Value; + +/// A rule for auto-accepting tool calls +#[derive(Debug, Clone)] +pub enum AutoAcceptRule { + /// Auto-accept Edit tool calls that change at most N lines + SmallEdit { max_lines: usize }, + /// Auto-accept Bash tool calls matching these command prefixes + BashCommand { prefixes: Vec<String> }, + /// Auto-accept specific read-only tools unconditionally + ReadOnlyTool { tools: Vec<String> }, +} + +impl AutoAcceptRule { + /// Check if this rule matches the given tool call + fn matches(&self, tool_name: &str, tool_input: &Value) -> bool { + match self { + AutoAcceptRule::SmallEdit { max_lines } => { + if let Some(file_update) = FileUpdate::from_tool_call(tool_name, tool_input) { + file_update.is_small_edit(*max_lines) + } else { + false + } + } + AutoAcceptRule::BashCommand { prefixes } => { + if tool_name != "Bash" { + return false; + } + let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) else { + return false; + }; + let command_trimmed = command.trim(); + prefixes + .iter() + .any(|prefix| command_trimmed.starts_with(prefix)) + } + AutoAcceptRule::ReadOnlyTool { tools } => tools.iter().any(|t| t == tool_name), + } + } +} + +/// Collection of auto-accept rules +#[derive(Debug, Clone)] +pub struct AutoAcceptRules { + rules: Vec<AutoAcceptRule>, +} + +impl Default for AutoAcceptRules { + fn default() -> Self { + Self { + rules: vec![ + AutoAcceptRule::SmallEdit { max_lines: 2 }, + AutoAcceptRule::BashCommand { + prefixes: vec![ + "cargo build".into(), + "cargo check".into(), + "cargo test".into(), + "cargo fmt".into(), + "cargo clippy".into(), + ], + }, + AutoAcceptRule::ReadOnlyTool { + tools: vec!["Glob".into(), "Grep".into(), "Read".into()], + }, + ], + } + } +} + +impl AutoAcceptRules { + /// Check if any rule matches the given tool call + pub fn should_auto_accept(&self, tool_name: &str, tool_input: &Value) -> bool { + self.rules + .iter() + .any(|rule| rule.matches(tool_name, tool_input)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn default_rules() -> AutoAcceptRules { + AutoAcceptRules::default() + } + + #[test] + fn test_small_edit_auto_accept() { + let rules = default_rules(); + let input = json!({ + "file_path": "/path/to/file.rs", + "old_string": "let x = 1;", + "new_string": "let x = 2;" + }); + assert!(rules.should_auto_accept("Edit", &input)); + } + + #[test] + fn test_large_edit_not_auto_accept() { + let rules = default_rules(); + let input = json!({ + "file_path": "/path/to/file.rs", + "old_string": "line1\nline2\nline3\nline4", + "new_string": "a\nb\nc\nd" + }); + assert!(!rules.should_auto_accept("Edit", &input)); + } + + #[test] + fn test_cargo_build_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "cargo build" }); + assert!(rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_cargo_check_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "cargo check" }); + assert!(rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_cargo_test_with_args_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "cargo test --release" }); + assert!(rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_cargo_fmt_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "cargo fmt" }); + assert!(rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_cargo_clippy_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "cargo clippy" }); + assert!(rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_rm_not_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "rm -rf /tmp/test" }); + assert!(!rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_curl_not_auto_accept() { + let rules = default_rules(); + let input = json!({ "command": "curl https://example.com" }); + assert!(!rules.should_auto_accept("Bash", &input)); + } + + #[test] + fn test_read_auto_accept() { + let rules = default_rules(); + let input = json!({ "file_path": "/path/to/file.rs" }); + assert!(rules.should_auto_accept("Read", &input)); + } + + #[test] + fn test_glob_auto_accept() { + let rules = default_rules(); + let input = json!({ "pattern": "**/*.rs" }); + assert!(rules.should_auto_accept("Glob", &input)); + } + + #[test] + fn test_grep_auto_accept() { + let rules = default_rules(); + let input = json!({ "pattern": "TODO", "path": "/src" }); + assert!(rules.should_auto_accept("Grep", &input)); + } + + #[test] + fn test_write_not_auto_accept() { + let rules = default_rules(); + let input = json!({ + "file_path": "/path/to/file.rs", + "content": "new content" + }); + assert!(!rules.should_auto_accept("Write", &input)); + } + + #[test] + fn test_unknown_tool_not_auto_accept() { + let rules = default_rules(); + let input = json!({}); + assert!(!rules.should_auto_accept("UnknownTool", &input)); + } + + #[test] + fn test_bash_with_leading_whitespace() { + let rules = default_rules(); + let input = json!({ "command": " cargo build" }); + assert!(rules.should_auto_accept("Bash", &input)); + } +} diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -1,9 +1,9 @@ +use crate::auto_accept::AutoAcceptRules; use crate::backend::session_info::parse_session_info; use crate::backend::tool_summary::{ extract_response_content, format_tool_summary, truncate_output, }; use crate::backend::traits::AiBackend; -use crate::file_update::FileUpdate; use crate::messages::{ CompactionInfo, DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse, SubagentInfo, SubagentStatus, ToolResult, @@ -293,14 +293,12 @@ async fn session_actor( // Handle permission requests (they're blocking the SDK) Some(perm_req) = perm_rx.recv() => { - // Auto-accept small edits (2 lines or less) - const AUTO_ACCEPT_MAX_LINES: usize = 2; - if let Some(file_update) = FileUpdate::from_tool_call(&perm_req.tool_name, &perm_req.tool_input) { - if file_update.is_small_edit(AUTO_ACCEPT_MAX_LINES) { - tracing::debug!("Auto-accepting small edit ({} lines max): {}", AUTO_ACCEPT_MAX_LINES, file_update.file_path); - let _ = perm_req.response_tx.send(PermissionResult::Allow(PermissionResultAllow::default())); - continue; - } + // Check auto-accept rules + let auto_accept_rules = AutoAcceptRules::default(); + if auto_accept_rules.should_auto_accept(&perm_req.tool_name, &perm_req.tool_input) { + tracing::debug!("Auto-accepting {}: matched auto-accept rule", perm_req.tool_name); + let _ = perm_req.response_tx.send(PermissionResult::Allow(PermissionResultAllow::default())); + continue; } // Forward permission request to UI diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1,4 +1,5 @@ mod agent_status; +mod auto_accept; mod avatar; mod backend; mod config;