fix(approval): cache denials per session — ESC on dangerous command stops re-prompting (#360)
When the user pressed ESC (or Deny / Abort) on an approval prompt, the TUI correctly told the engine to deny the call. But the model would often retry the same command — same name, same args, same approval fingerprint — and the user would see the dialog again, frustrating in the same way the equivalent yes-yes-yes loop would be. Symmetric to the existing `approval_session_approved` "always approve" cache: add `approval_session_denied: HashSet<String>` populated when the user denies (not when the timeout fired — a timeout might mean the user stepped away rather than refused). Subsequent ApprovalRequired events whose approval_key or tool_name match the cache auto-deny via `engine.deny_tool_call(...)` without re-showing the dialog. Logged via `tool.approval.auto_deny_session` so the audit log captures the silent denial. Closes #360. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -556,6 +556,12 @@ pub struct App {
|
||||
pub clipboard: ClipboardHandler,
|
||||
// Tool approval session allowlist
|
||||
pub approval_session_approved: HashSet<String>,
|
||||
/// Approval keys (or tool names) the user has denied or aborted in
|
||||
/// this session. Subsequent re-requests for the same approval key
|
||||
/// auto-deny without re-prompting (#360) — the model can retry a
|
||||
/// dangerous command after being told no, but the user shouldn't
|
||||
/// have to keep dismissing the same dialog.
|
||||
pub approval_session_denied: HashSet<String>,
|
||||
pub approval_mode: ApprovalMode,
|
||||
// Modal view stack (approval/help/etc.)
|
||||
pub view_stack: ViewStack,
|
||||
@@ -1021,6 +1027,7 @@ impl App {
|
||||
yolo_restore,
|
||||
clipboard: ClipboardHandler::new(),
|
||||
approval_session_approved: HashSet::new(),
|
||||
approval_session_denied: HashSet::new(),
|
||||
approval_mode: if matches!(initial_mode, AppMode::Yolo) {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
|
||||
@@ -968,7 +968,24 @@ async fn run_event_loop(
|
||||
let session_approved =
|
||||
app.approval_session_approved.contains(&approval_key)
|
||||
|| app.approval_session_approved.contains(&tool_name);
|
||||
if session_approved || app.approval_mode == ApprovalMode::Auto {
|
||||
let session_denied =
|
||||
app.approval_session_denied.contains(&approval_key)
|
||||
|| app.approval_session_denied.contains(&tool_name);
|
||||
if session_denied {
|
||||
// The user already said no to this exact tool /
|
||||
// approval key in this session; auto-deny so the
|
||||
// model's retry loop doesn't keep re-prompting
|
||||
// (#360).
|
||||
log_sensitive_event(
|
||||
"tool.approval.auto_deny_session",
|
||||
serde_json::json!({
|
||||
"tool_name": tool_name,
|
||||
"approval_key": approval_key,
|
||||
"session_id": app.current_session_id,
|
||||
}),
|
||||
);
|
||||
let _ = engine_handle.deny_tool_call(id.clone()).await;
|
||||
} else if session_approved || app.approval_mode == ApprovalMode::Auto {
|
||||
log_sensitive_event(
|
||||
"tool.approval.auto_approve",
|
||||
serde_json::json!({
|
||||
@@ -3917,7 +3934,7 @@ async fn handle_view_events(
|
||||
// Store both the tool name (backward compat) and the
|
||||
// approval key (fingerprint-based).
|
||||
app.approval_session_approved.insert(tool_name.clone());
|
||||
app.approval_session_approved.insert(approval_key);
|
||||
app.approval_session_approved.insert(approval_key.clone());
|
||||
}
|
||||
|
||||
match decision {
|
||||
@@ -3925,6 +3942,15 @@ async fn handle_view_events(
|
||||
let _ = engine_handle.approve_tool_call(tool_id).await;
|
||||
}
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
// Cache the denial so the model retry-loop doesn't
|
||||
// re-prompt for the same command (#360). Only when
|
||||
// the user actively denied (not when the timeout
|
||||
// fired) — a timeout might mean the user stepped
|
||||
// away rather than refused.
|
||||
if !timed_out {
|
||||
app.approval_session_denied.insert(tool_name.clone());
|
||||
app.approval_session_denied.insert(approval_key);
|
||||
}
|
||||
let _ = engine_handle.deny_tool_call(tool_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user