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:
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;