| 1 | //! UI-side approval disposition and durable denial receipts. |
| 2 | |
| 3 | use crate::audit::log_sensitive_event; |
| 4 | use crate::core::engine::EngineHandle; |
| 5 | use crate::tui::app::{App, StatusToastLevel}; |
| 6 | use crate::tui::history::HistoryCell; |
| 7 | use codewhale_execpolicy::ApprovalMode; |
| 8 | use codewhale_localization::MessageId; |
| 9 | |
| 10 | pub(super) fn is_session_approved_for_tool( |
| 11 | app: &App, |
| 12 | _tool_name: &str, |
| 13 | grouping_key: &str, |
| 14 | ) -> bool { |
| 15 | // Session grants match the grouping key only (command family / host / |
| 16 | // patch paths). A bare tool name is never session-wide: approving one |
| 17 | // shell command used to auto-approve the entire shell tool for the |
| 18 | // session. The `contains(tool_name)` clause was the escalation (ops R2). |
| 19 | app.approval_session_approved.contains(grouping_key) |
| 20 | } |
| 21 | |
| 22 | pub(super) fn is_session_denied_for_key(app: &App, approval_key: &str) -> bool { |
| 23 | app.approval_session_denied.contains(approval_key) |
| 24 | } |
| 25 | |
| 26 | /// A Deny holds for the rest of the user turn it was given in: the model's |
| 27 | /// retry loop must not re-prompt for the same call, but the user's next |
| 28 | /// message is a new intent and may deserve a different answer. |
| 29 | pub(super) fn end_turn_scoped_denials(app: &mut App) { |
| 30 | app.approval_session_denied.clear(); |
| 31 | } |
| 32 | |
| 33 | /// A different conversation (a session switch or resume) inherits neither |
| 34 | /// this conversation's denials nor its "approve for session" grants: both |
| 35 | /// describe work the user was looking at here. `/new` and `/clear` do the |
| 36 | /// same in `reset_conversation_state`. |
| 37 | pub(super) fn reset_approval_scope_for_new_conversation(app: &mut App) { |
| 38 | app.approval_session_denied.clear(); |
| 39 | app.approval_session_approved.clear(); |
| 40 | crate::tui::pending_requests::clear_all(app); |
| 41 | } |
| 42 | |
| 43 | pub(super) fn session_denied_notice(app: &App, tool_name: &str) -> String { |
| 44 | app.tr(MessageId::ApprovalAutoDeniedSession) |
| 45 | .replace("{tool}", tool_name) |
| 46 | } |
| 47 | |
| 48 | pub(super) fn surface_session_denied_notice(app: &mut App, tool_name: &str) { |
| 49 | let notice = session_denied_notice(app, tool_name); |
| 50 | app.push_status_toast(notice.clone(), StatusToastLevel::Warning, Some(12_000)); |
| 51 | |
| 52 | // Tool completion and turn completion can replace the one-line status |
| 53 | // before the next frame is painted. Keep the recovery path in the |
| 54 | // transcript as a settled receipt as well, where it survives that event |
| 55 | // ordering and remains available to screen readers and scrollback. |
| 56 | let latest_transcript_cell = app |
| 57 | .active_cell |
| 58 | .as_ref() |
| 59 | .and_then(|cell| cell.entries().last()) |
| 60 | .or_else(|| app.history.last()); |
| 61 | let already_latest_receipt = matches!( |
| 62 | latest_transcript_cell, |
| 63 | Some(HistoryCell::System { content }) if content == ¬ice |
| 64 | ); |
| 65 | if !already_latest_receipt { |
| 66 | let receipt = HistoryCell::System { content: notice }; |
| 67 | if let Some(active_cell) = app.active_cell.as_mut() { |
| 68 | // Never grow committed history underneath an active cell: tool |
| 69 | // lookup indices address `history ++ active_cell`, so changing |
| 70 | // history.len() mid-turn would retarget the pending completion. |
| 71 | active_cell.push_untracked(receipt); |
| 72 | app.bump_active_cell_revision(); |
| 73 | } else { |
| 74 | app.add_message(receipt); |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | pub(super) async fn auto_deny_session_approval( |
| 80 | app: &mut App, |
| 81 | engine_handle: &EngineHandle, |
| 82 | id: &str, |
| 83 | tool_name: &str, |
| 84 | approval_key: &str, |
| 85 | ) { |
| 86 | log_sensitive_event( |
| 87 | "tool.approval.auto_deny_session", |
| 88 | serde_json::json!({ |
| 89 | "tool_name": tool_name, |
| 90 | "approval_key": approval_key, |
| 91 | "session_id": app.current_session_id, |
| 92 | }), |
| 93 | ); |
| 94 | // The notice claims the call was denied; say so only when the denial |
| 95 | // reached the engine (U03-06). A failed send means the engine's approval |
| 96 | // mailbox is closed, so nothing is left waiting on this decision. |
| 97 | if let Err(error) = engine_handle |
| 98 | .deny_tool_call_by( |
| 99 | id.to_string(), |
| 100 | crate::approval_log::ApprovalDecider::SessionRule, |
| 101 | ) |
| 102 | .await |
| 103 | { |
| 104 | tracing::warn!(tool_name, %error, "session-rule denial did not reach the engine"); |
| 105 | return; |
| 106 | } |
| 107 | surface_session_denied_notice(app, tool_name); |
| 108 | } |
| 109 | |
| 110 | pub(super) fn app_auto_approve_enabled(app: &App) -> bool { |
| 111 | app.approval_mode == ApprovalMode::Bypass |
| 112 | } |
| 113 | |
| 114 | /// Build the UI-side TurnAuthority for approval disposition (#4412). |
| 115 | /// |
| 116 | /// Shell/trust bits do not affect disposition; mode + approval_mode + the |
| 117 | /// full-access shape (Bypass) are what the shared resolver consults. |
| 118 | fn app_turn_authority_for_approvals(app: &App) -> crate::core::authority::TurnAuthority { |
| 119 | crate::core::authority::TurnAuthority::from_effective_fields( |
| 120 | app.mode, |
| 121 | true, |
| 122 | false, |
| 123 | app_auto_approve_enabled(app), |
| 124 | app.approval_mode, |
| 125 | ) |
| 126 | } |
| 127 | |
| 128 | /// Who answered an `AutoApprove` disposition: the posture when it allows the |
| 129 | /// call on its own, otherwise the remembered session rule that did. |
| 130 | pub(super) fn auto_approval_decider( |
| 131 | app: &App, |
| 132 | approval_force_prompt: bool, |
| 133 | ) -> crate::approval_log::ApprovalDecider { |
| 134 | use crate::core::authority::ApprovalRequestDisposition; |
| 135 | match crate::core::authority::resolve_approval_request_disposition( |
| 136 | &app_turn_authority_for_approvals(app), |
| 137 | false, |
| 138 | false, |
| 139 | approval_force_prompt, |
| 140 | false, |
| 141 | ) { |
| 142 | ApprovalRequestDisposition::AutoApprove => crate::approval_log::ApprovalDecider::Posture, |
| 143 | _ => crate::approval_log::ApprovalDecider::SessionRule, |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | pub(super) fn resolve_ui_approval_disposition( |
| 148 | app: &App, |
| 149 | tool_name: &str, |
| 150 | grouping_key: &str, |
| 151 | approval_key: &str, |
| 152 | approval_force_prompt: bool, |
| 153 | ) -> crate::core::authority::ApprovalRequestDisposition { |
| 154 | crate::core::authority::resolve_approval_request_disposition( |
| 155 | &app_turn_authority_for_approvals(app), |
| 156 | is_session_approved_for_tool(app, tool_name, grouping_key), |
| 157 | is_session_denied_for_key(app, approval_key), |
| 158 | approval_force_prompt, |
| 159 | // This namespace is minted by the Engine, not by plugin card text. |
| 160 | approval_key.starts_with("extcall:ext:"), |
| 161 | ) |
| 162 | } |
| 163 | |
| 164 | /// Answer, explicitly, a request that must not open a card here, so nothing |
| 165 | /// waits on a card that never shows (approvals C1): |
| 166 | /// |
| 167 | /// - a child agent's approval from another conversation (the agent is known |
| 168 | /// to belong elsewhere) is answered `unavailable`; |
| 169 | /// - while the parent is idle or its turn was cancelled locally, a request |
| 170 | /// the parent owns can only be stale: an approval or sandbox elevation is |
| 171 | /// answered `unavailable`, a question is cancelled. Neither is recorded |
| 172 | /// as the person's denial. |
| 173 | /// |
| 174 | /// A child agent's request from this conversation is never stale on the |
| 175 | /// idle/cancel basis: the child is still running and waiting on the person, |
| 176 | /// so it falls through to the normal handler. Returns `true` when the event |
| 177 | /// was consumed here. |
| 178 | pub(super) async fn resolve_stale_parent_request( |
| 179 | app: &App, |
| 180 | engine_handle: &EngineHandle, |
| 181 | event: &crate::core::events::Event, |
| 182 | ) -> bool { |
| 183 | use crate::core::events::Event; |
| 184 | if let Event::ApprovalRequired { id, tool_name, .. } = event |
| 185 | && crate::tui::pending_requests::is_foreign_child_request(app, id) |
| 186 | { |
| 187 | log_sensitive_event( |
| 188 | "tool.approval.foreign_session_child_resolved", |
| 189 | serde_json::json!({ |
| 190 | "tool_name": tool_name, |
| 191 | "session_id": app.current_session_id, |
| 192 | }), |
| 193 | ); |
| 194 | let _ = engine_handle.deny_tool_call_unavailable(id.clone()).await; |
| 195 | return true; |
| 196 | } |
| 197 | if !(app.suppress_stream_events_until_turn_complete || !app.is_loading) { |
| 198 | return false; |
| 199 | } |
| 200 | match event { |
| 201 | Event::ApprovalRequired { id, tool_name, .. } |
| 202 | if !crate::tools::subagent::SubAgentManager::is_child_approval_id(id) => |
| 203 | { |
| 204 | log_sensitive_event( |
| 205 | "tool.approval.stale_parent_resolved", |
| 206 | serde_json::json!({ |
| 207 | "tool_name": tool_name, |
| 208 | "session_id": app.current_session_id, |
| 209 | }), |
| 210 | ); |
| 211 | let _ = engine_handle.deny_tool_call_unavailable(id.clone()).await; |
| 212 | true |
| 213 | } |
| 214 | Event::ElevationRequired { |
| 215 | tool_id, tool_name, .. |
| 216 | } => { |
| 217 | log_sensitive_event( |
| 218 | "tool.sandbox.stale_elevation_resolved", |
| 219 | serde_json::json!({ |
| 220 | "tool_name": tool_name, |
| 221 | "session_id": app.current_session_id, |
| 222 | }), |
| 223 | ); |
| 224 | let _ = engine_handle |
| 225 | .deny_tool_call_unavailable(tool_id.clone()) |
| 226 | .await; |
| 227 | true |
| 228 | } |
| 229 | Event::UserInputRequired { id, .. } |
| 230 | if !crate::tools::subagent::SubAgentManager::is_child_approval_id(id) => |
| 231 | { |
| 232 | log_sensitive_event( |
| 233 | "tool.user_input.stale_parent_resolved", |
| 234 | serde_json::json!({ |
| 235 | "tool_id": id, |
| 236 | "session_id": app.current_session_id, |
| 237 | }), |
| 238 | ); |
| 239 | let _ = engine_handle.cancel_user_input(id.clone()).await; |
| 240 | true |
| 241 | } |
| 242 | _ => false, |
| 243 | } |
| 244 | } |
| 245 |