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