commit 8ea781195ada298d6fd40addb3bac8324ef7e8ab
parent b1643e63db2b42ba58b8b2ac85627c4a63f2f904
Author: William Casarin <jb55@jb55.com>
Date: Sun, 25 Jan 2026 18:59:56 -0800
dave: add interactive permission handling for Claude Code tools
Replace BypassPermissions mode with interactive permission handling that
surfaces tool permission requests to the user in the UI. When Claude Code
wants to use a tool (Write, Bash, Edit, etc.), a permission request is
displayed inline in the chat with Allow/Deny buttons.
Architecture:
- PermissionRequest: displayable data (id, tool_name, tool_input)
- PendingPermission: request + oneshot::Sender for response
- can_use_tool callback sends requests to UI via channel
- UI stores response senders in session.pending_permissions
- Button clicks send PermissionResponse back through stored sender
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
7 files changed, 281 insertions(+), 27 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4140,6 +4140,7 @@ dependencies = [
"sha2",
"tokio",
"tracing",
+ "uuid",
]
[[package]]
diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml
@@ -21,6 +21,7 @@ nostrdb = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
rand = "0.9.0"
+uuid = { version = "1", features = ["v4"] }
bytemuck = "1.22.0"
futures = "0.3.31"
#reqwest = "0.12.15"
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -1,14 +1,18 @@
use crate::backend::traits::AiBackend;
-use crate::messages::DaveApiResponse;
+use crate::messages::{DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse};
use crate::tools::Tool;
use crate::Message;
use claude_agent_sdk_rs::{
- query_stream, ClaudeAgentOptions, ContentBlock, Message as ClaudeMessage, TextBlock,
+ query_stream, ClaudeAgentOptions, ContentBlock, Message as ClaudeMessage, PermissionResult,
+ PermissionResultAllow, PermissionResultDeny, TextBlock,
};
+use futures::future::BoxFuture;
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::mpsc;
use std::sync::Arc;
+use tokio::sync::oneshot;
+use uuid::Uuid;
pub struct ClaudeBackend {
api_key: String,
@@ -46,8 +50,11 @@ impl ClaudeBackend {
prompt.push_str(content);
prompt.push_str("\n\n");
}
- Message::ToolCalls(_) | Message::ToolResponse(_) | Message::Error(_) => {
- // Skip tool-related and error messages
+ Message::ToolCalls(_)
+ | Message::ToolResponse(_)
+ | Message::Error(_)
+ | Message::PermissionRequest(_) => {
+ // Skip tool-related, error, and permission messages
}
}
}
@@ -68,6 +75,9 @@ impl AiBackend for ClaudeBackend {
let (tx, rx) = mpsc::channel();
let _api_key = self.api_key.clone();
+ let tx_for_callback = tx.clone();
+ let ctx_for_callback = ctx.clone();
+
tokio::spawn(async move {
let prompt = ClaudeBackend::messages_to_prompt(&messages);
@@ -83,8 +93,79 @@ impl AiBackend for ClaudeBackend {
tracing::trace!("Claude CLI stderr: {}", msg);
};
+ // Permission callback - sends requests to UI and waits for user response
+ let can_use_tool: Arc<
+ dyn Fn(
+ String,
+ serde_json::Value,
+ claude_agent_sdk_rs::ToolPermissionContext,
+ ) -> BoxFuture<'static, PermissionResult>
+ + Send
+ + Sync,
+ > = Arc::new({
+ let tx = tx_for_callback;
+ let ctx = ctx_for_callback;
+ move |tool_name: String,
+ tool_input: serde_json::Value,
+ _context: claude_agent_sdk_rs::ToolPermissionContext| {
+ let tx = tx.clone();
+ let ctx = ctx.clone();
+ Box::pin(async move {
+ let (response_tx, response_rx) = oneshot::channel();
+
+ let request = PermissionRequest {
+ id: Uuid::new_v4(),
+ tool_name: tool_name.clone(),
+ tool_input: tool_input.clone(),
+ };
+
+ let pending = PendingPermission {
+ request,
+ response_tx,
+ };
+
+ // Send permission request to UI
+ if tx
+ .send(DaveApiResponse::PermissionRequest(pending))
+ .is_err()
+ {
+ tracing::error!("Failed to send permission request to UI");
+ return PermissionResult::Deny(PermissionResultDeny {
+ message: "UI channel closed".to_string(),
+ interrupt: true,
+ });
+ }
+
+ ctx.request_repaint();
+
+ // Wait for user response
+ match response_rx.await {
+ Ok(PermissionResponse::Allow) => {
+ tracing::debug!("User allowed tool: {}", tool_name);
+ PermissionResult::Allow(PermissionResultAllow::default())
+ }
+ Ok(PermissionResponse::Deny { reason }) => {
+ tracing::debug!("User denied tool {}: {}", tool_name, reason);
+ PermissionResult::Deny(PermissionResultDeny {
+ message: reason,
+ interrupt: false,
+ })
+ }
+ Err(_) => {
+ tracing::error!("Permission response channel closed");
+ PermissionResult::Deny(PermissionResultDeny {
+ message: "Permission request cancelled".to_string(),
+ interrupt: true,
+ })
+ }
+ }
+ })
+ }
+ });
+
let options = ClaudeAgentOptions::builder()
.stderr_callback(Arc::new(stderr_callback))
+ .can_use_tool(can_use_tool)
.build();
let mut stream = match query_stream(prompt, Some(options)).await {
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -21,7 +21,7 @@ use std::sync::Arc;
pub use avatar::DaveAvatar;
pub use config::{AiProvider, DaveSettings, ModelConfig};
-pub use messages::{DaveApiResponse, Message};
+pub use messages::{DaveApiResponse, Message, PermissionResponse};
pub use quaternion::Quaternion;
pub use session::{ChatSession, SessionId, SessionManager};
pub use tools::{
@@ -71,7 +71,7 @@ impl Dave {
self.avatar.as_mut()
}
- fn system_prompt() -> Message {
+ fn _system_prompt() -> Message {
let now = Local::now();
let yesterday = now - Duration::hours(24);
let date = now.format("%Y-%m-%d %H:%M:%S");
@@ -220,6 +220,24 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
}
+
+ DaveApiResponse::PermissionRequest(pending) => {
+ tracing::info!(
+ "Permission request for tool '{}': {:?}",
+ pending.request.tool_name,
+ pending.request.tool_input
+ );
+
+ // Store the response sender for later
+ session
+ .pending_permissions
+ .insert(pending.request.id, pending.response_tx);
+
+ // Add the request to chat for UI display
+ session
+ .chat
+ .push(Message::PermissionRequest(pending.request));
+ }
}
}
@@ -409,6 +427,27 @@ impl notedeck::App for Dave {
DaveAction::UpdateSettings(settings) => {
dave_action = Some(DaveAction::UpdateSettings(settings));
}
+ DaveAction::PermissionResponse {
+ request_id,
+ response,
+ } => {
+ // Send the permission response back to the callback
+ if let Some(session) = self.session_manager.get_active_mut() {
+ if let Some(sender) = session.pending_permissions.remove(&request_id) {
+ if sender.send(response).is_err() {
+ tracing::error!(
+ "Failed to send permission response for request {}",
+ request_id
+ );
+ }
+ } else {
+ tracing::warn!(
+ "No pending permission found for request {}",
+ request_id
+ );
+ }
+ }
+ }
}
}
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -1,6 +1,36 @@
use crate::tools::{ToolCall, ToolResponse};
use async_openai::types::*;
use nostrdb::{Ndb, Transaction};
+use tokio::sync::oneshot;
+use uuid::Uuid;
+
+/// A request for user permission to use a tool (displayable data only)
+#[derive(Debug, Clone)]
+pub struct PermissionRequest {
+ /// Unique identifier for this permission request
+ pub id: Uuid,
+ /// The tool that wants to be used
+ pub tool_name: String,
+ /// The arguments the tool will be called with
+ pub tool_input: serde_json::Value,
+}
+
+/// A permission request with the response channel (for channel communication)
+pub struct PendingPermission {
+ /// The displayable request data
+ pub request: PermissionRequest,
+ /// Channel to send the user's response back
+ pub response_tx: oneshot::Sender<PermissionResponse>,
+}
+
+/// The user's response to a permission request
+#[derive(Debug, Clone)]
+pub enum PermissionResponse {
+ /// Allow the tool to execute
+ Allow,
+ /// Deny the tool execution with an optional reason
+ Deny { reason: String },
+}
#[derive(Debug, Clone)]
pub enum Message {
@@ -10,6 +40,8 @@ pub enum Message {
Assistant(String),
ToolCalls(Vec<ToolCall>),
ToolResponse(ToolResponse),
+ /// A permission request from the AI that needs user response
+ PermissionRequest(PermissionRequest),
}
/// The ai backends response. Since we are using streaming APIs these are
@@ -18,6 +50,8 @@ pub enum DaveApiResponse {
ToolCalls(Vec<ToolCall>),
Token(String),
Failed(String),
+ /// A permission request that needs to be displayed to the user
+ PermissionRequest(PendingPermission),
}
impl Message {
@@ -69,6 +103,9 @@ impl Message {
},
))
}
+
+ // Permission requests are UI-only, not sent to the API
+ Message::PermissionRequest(_) => None,
}
}
}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -1,7 +1,10 @@
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
+use crate::messages::PermissionResponse;
use crate::{DaveApiResponse, Message};
+use tokio::sync::oneshot;
+use uuid::Uuid;
pub type SessionId = u32;
@@ -12,6 +15,8 @@ pub struct ChatSession {
pub chat: Vec<Message>,
pub input: String,
pub incoming_tokens: Option<Receiver<DaveApiResponse>>,
+ /// Pending permission requests waiting for user response
+ pub pending_permissions: HashMap<Uuid, oneshot::Sender<PermissionResponse>>,
}
impl ChatSession {
@@ -22,6 +27,7 @@ impl ChatSession {
chat: vec![],
input: String::new(),
incoming_tokens: None,
+ pending_permissions: HashMap::new(),
}
}
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -1,6 +1,6 @@
use crate::{
config::DaveSettings,
- messages::Message,
+ messages::{Message, PermissionRequest, PermissionResponse},
tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
};
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
@@ -9,6 +9,7 @@ use notedeck::{
tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext,
};
use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic};
+use uuid::Uuid;
/// DaveUi holds all of the data it needs to render itself
pub struct DaveUi<'a> {
@@ -67,6 +68,11 @@ pub enum DaveAction {
OpenSettings,
/// Settings were updated and should be persisted
UpdateSettings(DaveSettings),
+ /// User responded to a permission request
+ PermissionResponse {
+ request_id: Uuid,
+ response: PermissionResponse,
+ },
}
impl<'a> DaveUi<'a> {
@@ -114,7 +120,7 @@ impl<'a> DaveUi<'a> {
.show(ui, |ui| self.inputbox(app_ctx.i18n, ui))
.inner;
- let note_action = egui::ScrollArea::vertical()
+ let chat_response = egui::ScrollArea::vertical()
.stick_to_bottom(true)
.auto_shrink([false; 2])
.show(ui, |ui| {
@@ -126,11 +132,7 @@ impl<'a> DaveUi<'a> {
})
.inner;
- if let Some(action) = note_action {
- DaveResponse::note(action)
- } else {
- r
- }
+ chat_response.or(r)
})
.inner
})
@@ -154,46 +156,133 @@ impl<'a> DaveUi<'a> {
}
/// Render a chat message (user, assistant, tool call/response, etc)
- fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<NoteAction> {
- let mut action: Option<NoteAction> = None;
+ fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
+ let mut response = DaveResponse::default();
for message in self.chat {
- let r = match message {
+ match message {
Message::Error(err) => {
self.error_chat(ctx.i18n, err, ui);
- None
}
Message::User(msg) => {
self.user_chat(msg, ui);
- None
}
Message::Assistant(msg) => {
self.assistant_chat(msg, ui);
- None
}
Message::ToolResponse(msg) => {
Self::tool_response_ui(msg, ui);
- None
}
Message::System(_msg) => {
// system prompt is not rendered. Maybe we could
// have a debug option to show this
- None
}
- Message::ToolCalls(toolcalls) => Self::tool_calls_ui(ctx, toolcalls, ui),
+ Message::ToolCalls(toolcalls) => {
+ if let Some(note_action) = Self::tool_calls_ui(ctx, toolcalls, ui) {
+ response = DaveResponse::note(note_action);
+ }
+ }
+ Message::PermissionRequest(request) => {
+ if let Some(action) = Self::permission_request_ui(request, ui) {
+ response = DaveResponse::new(action);
+ }
+ }
};
-
- if r.is_some() {
- action = r;
- }
}
- action
+ response
}
fn tool_response_ui(_tool_response: &ToolResponse, _ui: &mut egui::Ui) {
//ui.label(format!("tool_response: {:?}", tool_response));
}
+ /// Render a permission request with Allow/Deny buttons
+ fn permission_request_ui(request: &PermissionRequest, ui: &mut egui::Ui) -> Option<DaveAction> {
+ let mut action = None;
+
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(12.0)
+ .corner_radius(8.0)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
+ .show(ui, |ui| {
+ ui.vertical(|ui| {
+ // Header
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new("🔐").size(18.0));
+ ui.label(
+ egui::RichText::new(format!(
+ "Claude wants to use: {}",
+ request.tool_name
+ ))
+ .strong(),
+ );
+ });
+
+ ui.add_space(8.0);
+
+ // Tool arguments in a code-like box
+ egui::Frame::new()
+ .fill(ui.visuals().extreme_bg_color)
+ .inner_margin(8.0)
+ .corner_radius(4.0)
+ .show(ui, |ui| {
+ let formatted = serde_json::to_string_pretty(&request.tool_input)
+ .unwrap_or_else(|_| request.tool_input.to_string());
+ ui.add(
+ egui::Label::new(
+ egui::RichText::new(formatted).monospace().size(12.0),
+ )
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+ });
+
+ ui.add_space(12.0);
+
+ // Buttons
+ ui.horizontal(|ui| {
+ if ui
+ .add(
+ egui::Button::new(
+ egui::RichText::new("Allow")
+ .color(ui.visuals().widgets.active.fg_stroke.color),
+ )
+ .fill(egui::Color32::from_rgb(34, 139, 34)), // Forest green
+ )
+ .clicked()
+ {
+ action = Some(DaveAction::PermissionResponse {
+ request_id: request.id,
+ response: PermissionResponse::Allow,
+ });
+ }
+
+ ui.add_space(8.0);
+
+ if ui
+ .add(
+ egui::Button::new(
+ egui::RichText::new("Deny")
+ .color(ui.visuals().widgets.active.fg_stroke.color),
+ )
+ .fill(egui::Color32::from_rgb(178, 34, 34)), // Firebrick red
+ )
+ .clicked()
+ {
+ action = Some(DaveAction::PermissionResponse {
+ request_id: request.id,
+ response: PermissionResponse::Deny {
+ reason: "User denied".to_string(),
+ },
+ });
+ }
+ });
+ });
+ });
+
+ action
+ }
+
fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) {
ui.add(search_icon(16.0, 16.0));
ui.add_space(8.0);