返回 CodeWhale
approval_routing.rs
根目录 / crates / tui / src / tui / ui / approval_routing.rs
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 == &notice
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
245 lines RUST