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:
Hunter Bown
2026-05-02 10:35:25 -05:00
parent 162e2e027c
commit 735287774f
2 changed files with 35 additions and 2 deletions
+7
View File
@@ -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 {
+28 -2
View File
@@ -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;
}
}