返回 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 pub(super) fn session_denied_notice(app: &App, tool_name: &str) -> String {
27 app.tr(MessageId::ApprovalAutoDeniedSession)
28 .replace("{tool}", tool_name)
29 }
30
31 pub(super) fn surface_session_denied_notice(app: &mut App, tool_name: &str) {
32 let notice = session_denied_notice(app, tool_name);
33 app.push_status_toast(notice.clone(), StatusToastLevel::Warning, Some(12_000));
34
35 // Tool completion and turn completion can replace the one-line status
36 // before the next frame is painted. Keep the recovery path in the
37 // transcript as a settled receipt as well, where it survives that event
38 // ordering and remains available to screen readers and scrollback.
39 let latest_transcript_cell = app
40 .active_cell
41 .as_ref()
42 .and_then(|cell| cell.entries().last())
43 .or_else(|| app.history.last());
44 let already_latest_receipt = matches!(
45 latest_transcript_cell,
46 Some(HistoryCell::System { content }) if content == &notice
47 );
48 if !already_latest_receipt {
49 let receipt = HistoryCell::System { content: notice };
50 if let Some(active_cell) = app.active_cell.as_mut() {
51 // Never grow committed history underneath an active cell: tool
52 // lookup indices address `history ++ active_cell`, so changing
53 // history.len() mid-turn would retarget the pending completion.
54 active_cell.push_untracked(receipt);
55 app.bump_active_cell_revision();
56 } else {
57 app.add_message(receipt);
58 }
59 }
60 }
61
62 pub(super) async fn auto_deny_session_approval(
63 app: &mut App,
64 engine_handle: &EngineHandle,
65 id: &str,
66 tool_name: &str,
67 approval_key: &str,
68 ) {
69 log_sensitive_event(
70 "tool.approval.auto_deny_session",
71 serde_json::json!({
72 "tool_name": tool_name,
73 "approval_key": approval_key,
74 "session_id": app.current_session_id,
75 }),
76 );
77 let _ = engine_handle.deny_tool_call(id.to_string()).await;
78 surface_session_denied_notice(app, tool_name);
79 }
80
81 pub(super) fn app_auto_approve_enabled(app: &App) -> bool {
82 app.approval_mode == ApprovalMode::Bypass
83 }
84
85 /// Build the UI-side TurnAuthority for approval disposition (#4412).
86 ///
87 /// Shell/trust bits do not affect disposition; mode + approval_mode + the
88 /// full-access shape (Bypass) are what the shared resolver consults.
89 fn app_turn_authority_for_approvals(app: &App) -> crate::core::authority::TurnAuthority {
90 crate::core::authority::TurnAuthority::from_effective_fields(
91 app.mode,
92 true,
93 false,
94 app_auto_approve_enabled(app),
95 app.approval_mode,
96 )
97 }
98
99 pub(super) fn resolve_ui_approval_disposition(
100 app: &App,
101 tool_name: &str,
102 grouping_key: &str,
103 approval_key: &str,
104 approval_force_prompt: bool,
105 ) -> crate::core::authority::ApprovalRequestDisposition {
106 crate::core::authority::resolve_approval_request_disposition(
107 &app_turn_authority_for_approvals(app),
108 is_session_approved_for_tool(app, tool_name, grouping_key),
109 is_session_denied_for_key(app, approval_key),
110 approval_force_prompt,
111 )
112 }
113
114 pub(super) fn should_suppress_user_input_prompt(app: &App) -> bool {
115 // Legacy hosts may still report Yolo/auto-approve with a stale `Auto`
116 // enum. Canonicalize that shape to Full Access before applying the one
117 // posture that suppresses questions: genuine Auto-Review.
118 let effective_posture = if app_auto_approve_enabled(app) {
119 ApprovalMode::Bypass
120 } else {
121 app.approval_mode
122 };
123 !crate::core::authority::permission_posture_allows_questions(effective_posture)
124 }
125
125 lines RUST