notedeck

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

commit 70a6330256a01acc3bfd78f4eee1d66f6d3ec493
parent 2dcc323b12183e826f2c5f5bdb54937c805fa82b
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 12:29:03 -0800

dave: add "Allow Always" for per-session runtime permission allowlist

Press 3 or click "Always" on a permission request to allow it and
auto-accept future matching requests for the session. For Bash commands,
the binary name (first word) is remembered. For other tools, the tool
name itself is stored. Auto-accepted permissions show in chat as
already-allowed so the user can see what's being auto-accepted.

Works for both local sessions (immediate oneshot response) and remote
sessions (publishes allow event to relay). Also extracts remote
permission request handling into a standalone function.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 167++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/notedeck_dave/src/session.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 27+++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/keybindings.rs | 12+++++++++++-
Mcrates/notedeck_dave/src/ui/mod.rs | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/update.rs | 23+++++++++++++++++++++++
6 files changed, 260 insertions(+), 49 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1907,54 +1907,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr ))); } Some("permission_request") => { - if let Ok(content_json) = serde_json::from_str::<serde_json::Value>(content) - { - let tool_name = content_json["tool_name"] - .as_str() - .unwrap_or("unknown") - .to_string(); - let tool_input = content_json - .get("tool_input") - .cloned() - .unwrap_or(serde_json::Value::Null); - let perm_id = session_events::get_tag_value(note, "perm-id") - .and_then(|s| uuid::Uuid::parse_str(s).ok()) - .unwrap_or_else(uuid::Uuid::new_v4); - - // Check if we already responded - let response = if agentic.permissions.responded.contains(&perm_id) { - Some(crate::messages::PermissionResponseType::Allowed) - } else { - None - }; - - // Store the note ID for linking responses - agentic - .permissions - .request_note_ids - .insert(perm_id, *note.id()); - - // Parse plan markdown for ExitPlanMode requests - let cached_plan = if tool_name == "ExitPlanMode" { - tool_input - .get("plan") - .and_then(|v| v.as_str()) - .map(crate::messages::ParsedMarkdown::parse) - } else { - None - }; - - session.chat.push(Message::PermissionRequest( - crate::messages::PermissionRequest { - id: perm_id, - tool_name, - tool_input, - response, - answer_summary: None, - cached_plan, - }, - )); - } + handle_remote_permission_request( + note, + content, + agentic, + &mut session.chat, + secret_key, + &mut events_to_publish, + ); } Some("permission_response") => { // Track that this permission was responded to @@ -2984,6 +2944,23 @@ fn handle_permission_request( pending.request.tool_input ); + // Check runtime allowlist — auto-accept and show as already-allowed in chat + if let Some(agentic) = &session.agentic { + if agentic.should_runtime_allow(&pending.request.tool_name, &pending.request.tool_input) { + tracing::info!( + "runtime allow: auto-accepting '{}' for this session", + pending.request.tool_name, + ); + let _ = pending + .response_tx + .send(PermissionResponse::Allow { message: None }); + let mut request = pending.request; + request.response = Some(crate::messages::PermissionResponseType::Allowed); + session.chat.push(Message::PermissionRequest(request)); + return; + } + } + // Build and publish a proper permission request event // with perm-id, tool-name tags for remote clients if let Some(sk) = secret_key { @@ -3031,6 +3008,100 @@ fn handle_permission_request( .push(Message::PermissionRequest(pending.request)); } +/// Handle a remote permission request from a kind-1988 conversation event. +/// Checks runtime allowlist for auto-accept, otherwise adds to chat for UI display. +fn handle_remote_permission_request( + note: &nostrdb::Note, + content: &str, + agentic: &mut session::AgenticSessionData, + chat: &mut Vec<Message>, + secret_key: Option<&[u8; 32]>, + events_to_publish: &mut Vec<session_events::BuiltEvent>, +) { + let Ok(content_json) = serde_json::from_str::<serde_json::Value>(content) else { + return; + }; + let tool_name = content_json["tool_name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let tool_input = content_json + .get("tool_input") + .cloned() + .unwrap_or(serde_json::Value::Null); + let perm_id = session_events::get_tag_value(note, "perm-id") + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .unwrap_or_else(uuid::Uuid::new_v4); + + // Store the note ID for linking responses + agentic + .permissions + .request_note_ids + .insert(perm_id, *note.id()); + + // Runtime allowlist auto-accept + if agentic.should_runtime_allow(&tool_name, &tool_input) { + tracing::info!( + "runtime allow: auto-accepting remote '{}' for this session", + tool_name, + ); + agentic.permissions.responded.insert(perm_id); + if let Some(sk) = secret_key { + if let Some(sid) = agentic.event_session_id().map(|s| s.to_string()) { + if let Ok(evt) = session_events::build_permission_response_event( + &perm_id, + note.id(), + true, + None, + &sid, + sk, + ) { + events_to_publish.push(evt); + } + } + } + chat.push(Message::PermissionRequest( + crate::messages::PermissionRequest { + id: perm_id, + tool_name, + tool_input, + response: Some(crate::messages::PermissionResponseType::Allowed), + answer_summary: None, + cached_plan: None, + }, + )); + return; + } + + // Check if we already responded + let response = if agentic.permissions.responded.contains(&perm_id) { + Some(crate::messages::PermissionResponseType::Allowed) + } else { + None + }; + + // Parse plan markdown for ExitPlanMode requests + let cached_plan = if tool_name == "ExitPlanMode" { + tool_input + .get("plan") + .and_then(|v| v.as_str()) + .map(crate::messages::ParsedMarkdown::parse) + } else { + None + }; + + chat.push(Message::PermissionRequest( + crate::messages::PermissionRequest { + id: perm_id, + tool_name, + tool_input, + response, + answer_summary: None, + cached_plan, + }, + )); +} + /// Handle a remote permission response from a kind-1988 event. fn handle_remote_permission_response( note: &nostrdb::Note, diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -234,6 +234,10 @@ pub struct AgenticSessionData { pub compact_and_proceed: CompactAndProceedState, /// Accumulated usage metrics across queries in this session. pub usage: crate::messages::UsageInfo, + /// Runtime allowlist for auto-accepting permissions this session. + /// For Bash: stores binary names (first word of command). + /// For other tools: stores the tool name. + pub runtime_allows: HashSet<String>, } impl AgenticSessionData { @@ -268,9 +272,46 @@ impl AgenticSessionData { seen_note_ids: HashSet::new(), compact_and_proceed: CompactAndProceedState::Idle, usage: Default::default(), + runtime_allows: HashSet::new(), } } + /// Extract the runtime allow key from a permission request. + /// For Bash: first word of the command (binary name). + /// For other tools: the tool name itself. + fn runtime_allow_key(tool_name: &str, tool_input: &serde_json::Value) -> Option<String> { + if tool_name == "Bash" { + tool_input + .get("command") + .and_then(|v| v.as_str()) + .and_then(|cmd| cmd.split_whitespace().next()) + .map(|s| s.to_string()) + } else { + Some(tool_name.to_string()) + } + } + + /// Check if a permission request matches the runtime allowlist. + pub fn should_runtime_allow(&self, tool_name: &str, tool_input: &serde_json::Value) -> bool { + if let Some(key) = Self::runtime_allow_key(tool_name, tool_input) { + self.runtime_allows.contains(&key) + } else { + false + } + } + + /// Add a runtime allow rule from a permission request. + /// Returns the key that was added (for logging). + pub fn add_runtime_allow( + &mut self, + tool_name: &str, + tool_input: &serde_json::Value, + ) -> Option<String> { + let key = Self::runtime_allow_key(tool_name, tool_input)?; + self.runtime_allows.insert(key.clone()); + Some(key) + } + /// Get the session ID to use for live kind-1988 events. /// /// Prefers claude_session_id from SessionInfo, falls back to resume_session_id. diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -139,6 +139,12 @@ pub enum DaveAction { TentativeAccept, /// Enter tentative deny mode (Shift+click on No) TentativeDeny, + /// Allow always — add to session allowlist and accept + AllowAlways { + request_id: Uuid, + }, + /// Tentative allow always — add to session allowlist, enter message mode + TentativeAllowAlways, /// User responded to an AskUserQuestion QuestionResponse { request_id: Uuid, @@ -786,6 +792,16 @@ impl<'a> DaveUi<'a> { .show(ui) .on_hover_text("Press 2 to deny, Shift+2 to deny with message"); + // Always button (blue) — allow and don't ask again this session + let always_response = super::badge::ActionButton::new( + "Always", + egui::Color32::from_rgb(30, 100, 180), + button_text_color, + ) + .keybind("3") + .show(ui) + .on_hover_text("Press 3 to allow always for this session, Shift+3 with message"); + if deny_response.clicked() { if shift_held { *action = Some(DaveAction::TentativeDeny); @@ -810,6 +826,16 @@ impl<'a> DaveUi<'a> { } } + if always_response.clicked() { + if shift_held { + *action = Some(DaveAction::TentativeAllowAlways); + } else { + *action = Some(DaveAction::AllowAlways { + request_id: request.id, + }); + } + } + add_msg_link(ui, shift_held, action); } }); @@ -1434,6 +1460,7 @@ fn format_relative_time(instant: std::time::Instant) -> String { } /// Renders the status bar containing git status and toggle badges. +#[allow(clippy::too_many_arguments)] fn status_bar_ui( mut git_status: Option<&mut GitStatusCache>, is_agentic: bool, diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs @@ -12,6 +12,10 @@ pub enum KeyAction { TentativeAccept, /// Tentatively deny, waiting for message (Shift+2) TentativeDeny, + /// Allow always — add to session allowlist and accept (3) + AllowAlways, + /// Tentatively allow always, waiting for message (Shift+3) + TentativeAllowAlways, /// Cancel tentative state (Escape when tentative) CancelTentative, /// Switch to agent by number (0-indexed) @@ -193,18 +197,24 @@ pub fn check_keybindings( if i.modifiers.shift && i.key_pressed(Key::Num2) { return Some(KeyAction::TentativeDeny); } + // Shift+3: tentative allow always + if i.modifiers.shift && i.key_pressed(Key::Num3) { + return Some(KeyAction::TentativeAllowAlways); + } None }) { return Some(action); } - // Bare keypresses (no modifiers) for immediate accept/deny + // Bare keypresses (no modifiers) for immediate accept/deny/always if let Some(action) = ctx.input(|i| { if !i.modifiers.any() { if i.key_pressed(Key::Num1) { return Some(KeyAction::AcceptPermission); } else if i.key_pressed(Key::Num2) { return Some(KeyAction::DenyPermission); + } else if i.key_pressed(Key::Num3) { + return Some(KeyAction::AllowAlways); } } None diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -644,6 +644,28 @@ pub fn handle_key_action( set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); KeyActionResult::None } + KeyAction::AllowAlways => { + update::allow_always(session_manager); + if let Some(request_id) = update::first_pending_permission(session_manager) { + let result = update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Allow { message: None }, + ); + if let Some(session) = session_manager.get_active_mut() { + session.focus_requested = true; + } + if let Some(publish) = result { + return KeyActionResult::PublishPermissionResponse(publish); + } + } + KeyActionResult::None + } + KeyAction::TentativeAllowAlways => { + update::allow_always(session_manager); + set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); + KeyActionResult::None + } KeyAction::CancelTentative => { if let Some(session) = session_manager.get_active_mut() { if let Some(agentic) = &mut session.agentic { @@ -849,6 +871,23 @@ pub fn handle_ui_action( set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); UiActionResult::Handled } + DaveAction::AllowAlways { request_id } => { + update::allow_always(session_manager); + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Allow { message: None }, + ) + .map_or( + UiActionResult::Handled, + UiActionResult::PublishPermissionResponse, + ) + } + DaveAction::TentativeAllowAlways => { + update::allow_always(session_manager); + set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); + UiActionResult::Handled + } DaveAction::QuestionResponse { request_id, answers, diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -90,6 +90,29 @@ pub fn check_interrupt_timeout(pending_since: Option<Instant>) -> Option<Instant // Plan Mode // ============================================================================= +/// Add the current pending permission's tool to the session's runtime allowlist. +/// Returns the key that was added (for logging), or None if no pending permission. +pub fn allow_always(session_manager: &mut SessionManager) -> Option<String> { + let session = session_manager.get_active_mut()?; + let agentic = session.agentic.as_mut()?; + + // Find the last pending (unresponded) permission request + let (tool_name, tool_input) = session.chat.iter().rev().find_map(|msg| { + if let crate::messages::Message::PermissionRequest(req) = msg { + if req.response.is_none() { + return Some((req.tool_name.clone(), req.tool_input.clone())); + } + } + None + })?; + + let key = agentic.add_runtime_allow(&tool_name, &tool_input); + if let Some(ref k) = key { + tracing::info!("allow_always: added runtime allow for '{}'", k); + } + key +} + /// Cycle permission mode for the active session: Default → Plan → AcceptEdits → Default. /// Info needed to publish a permission mode command to a remote host. pub struct ModeCommandPublish {