返回 CodeWhale
elevation.rs
根目录 / crates / tui / src / tui / approval / elevation.rs
1 //! Sandbox-elevation request policy and modal state.
2 //!
3 //! Elevation remains a distinct post-denial authority boundary: this module
4 //! owns the retry policy options, request construction, interaction state, and
5 //! typed decision event without changing the initial approval flow.
6
7 use std::cell::RefCell;
8 use std::path::{Path, PathBuf};
9
10 use crossterm::event::{KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
11 use ratatui::layout::Rect;
12
13 use crate::sandbox::SandboxPolicy;
14 use crate::tui::shell_key_routing::{self, Focus, ShellBindingId};
15 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
16 use crate::tui::widgets::{ElevationWidget, Renderable};
17 use codewhale_localization::Locale;
18
19 /// Options for elevating sandbox permissions after a denial.
20 #[derive(Debug, Clone, PartialEq, Eq)]
21 pub enum ElevationOption {
22 /// Add network access to the sandbox policy.
23 WithNetwork,
24 /// Add write access to specific paths.
25 WithWriteAccess(Vec<PathBuf>),
26 /// Remove sandbox restrictions entirely (dangerous).
27 FullAccess,
28 /// Abort the tool execution.
29 Abort,
30 }
31
32 impl ElevationOption {
33 /// Get the display label for this option.
34 #[cfg(test)]
35 pub fn label(&self) -> &'static str {
36 match self {
37 ElevationOption::WithNetwork => "Allow outbound network",
38 ElevationOption::WithWriteAccess(_) => "Allow extra write access",
39 ElevationOption::FullAccess => "Full access (filesystem + network)",
40 ElevationOption::Abort => "Abort",
41 }
42 }
43
44 /// Get a short description.
45 #[cfg(test)]
46 pub fn description(&self) -> &'static str {
47 match self {
48 ElevationOption::WithNetwork => "Retry with outbound network (downloads and HTTP)",
49 ElevationOption::WithWriteAccess(_) => "Retry with a wider writable scope",
50 ElevationOption::FullAccess => {
51 "Retry without sandbox limits; grants unrestricted filesystem and network access"
52 }
53 ElevationOption::Abort => "Cancel this run",
54 }
55 }
56
57 /// Convert to a sandbox policy.
58 pub fn to_policy(&self, base_cwd: &Path) -> SandboxPolicy {
59 match self {
60 ElevationOption::WithNetwork => SandboxPolicy::workspace_with_network(),
61 ElevationOption::WithWriteAccess(paths) => {
62 let mut roots = paths.clone();
63 roots.push(base_cwd.to_path_buf());
64 SandboxPolicy::workspace_with_roots(roots, false)
65 }
66 ElevationOption::FullAccess => SandboxPolicy::DangerFullAccess,
67 ElevationOption::Abort => SandboxPolicy::default(), // Won't be used
68 }
69 }
70 }
71
72 /// Request for user decision after a sandbox denial.
73 #[derive(Debug, Clone)]
74 pub struct ElevationRequest {
75 /// The tool ID that was blocked.
76 pub tool_id: String,
77 /// The tool name.
78 pub tool_name: String,
79 /// The command that was blocked (if shell).
80 pub command: Option<String>,
81 /// The reason for denial (from sandbox).
82 pub denial_reason: String,
83 /// Available elevation options.
84 pub options: Vec<ElevationOption>,
85 }
86
87 impl ElevationRequest {
88 /// Create a new elevation request for a shell command.
89 pub fn for_shell(
90 tool_id: &str,
91 command: &str,
92 denial_reason: &str,
93 blocked_network: bool,
94 blocked_write: bool,
95 ) -> Self {
96 let mut options = Vec::new();
97
98 if blocked_network {
99 options.push(ElevationOption::WithNetwork);
100 }
101 if blocked_write {
102 options.push(ElevationOption::WithWriteAccess(vec![]));
103 }
104 options.push(ElevationOption::FullAccess);
105 options.push(ElevationOption::Abort);
106
107 Self {
108 tool_id: tool_id.to_string(),
109 tool_name: "exec_shell".to_string(),
110 command: Some(command.to_string()),
111 denial_reason: denial_reason.to_string(),
112 options,
113 }
114 }
115
116 /// Create a generic elevation request.
117 #[cfg_attr(not(test), expect(dead_code))]
118 pub fn generic(tool_id: &str, tool_name: &str, denial_reason: &str) -> Self {
119 Self {
120 tool_id: tool_id.to_string(),
121 tool_name: tool_name.to_string(),
122 command: None,
123 denial_reason: denial_reason.to_string(),
124 options: vec![
125 ElevationOption::WithNetwork,
126 ElevationOption::FullAccess,
127 ElevationOption::Abort,
128 ],
129 }
130 }
131 }
132
133 /// Elevation overlay state managed by the modal view stack.
134 #[derive(Debug, Clone)]
135 pub struct ElevationView {
136 request: ElevationRequest,
137 pub(super) selected: usize,
138 locale: Locale,
139 row_hitboxes: RefCell<Vec<Rect>>,
140 }
141
142 impl ElevationView {
143 pub fn new(request: ElevationRequest, locale: Locale) -> Self {
144 // Every request offers Abort. A reflexive Enter must never broaden
145 // access before the person deliberately navigates to another option.
146 let selected = request
147 .options
148 .iter()
149 .position(|option| matches!(option, ElevationOption::Abort))
150 .expect("elevation requests offer Abort");
151 Self {
152 request,
153 selected,
154 locale,
155 row_hitboxes: RefCell::new(Vec::new()),
156 }
157 }
158
159 fn select_prev(&mut self) {
160 self.selected =
161 crate::tui::list_nav::wrap_index(self.selected, self.request.options.len(), -1);
162 }
163
164 fn select_next(&mut self) {
165 self.selected =
166 crate::tui::list_nav::wrap_index(self.selected, self.request.options.len(), 1);
167 }
168
169 fn current_option(&self) -> &ElevationOption {
170 &self.request.options[self.selected]
171 }
172
173 fn emit_decision(&self, option: ElevationOption) -> ViewAction {
174 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
175 tool_id: self.request.tool_id.clone(),
176 tool_name: self.request.tool_name.clone(),
177 option,
178 })
179 }
180
181 /// Get the request for rendering.
182 #[cfg_attr(not(test), expect(dead_code))]
183 pub fn request(&self) -> &ElevationRequest {
184 &self.request
185 }
186
187 /// Get the currently selected index.
188 #[expect(dead_code)]
189 pub fn selected(&self) -> usize {
190 self.selected
191 }
192 }
193
194 impl ModalView for ElevationView {
195 fn kind(&self) -> ModalKind {
196 ModalKind::Elevation
197 }
198
199 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
200 self
201 }
202
203 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
204 if key.kind != KeyEventKind::Press {
205 return ViewAction::None;
206 }
207 // Ordinary text may have been intended for the composer. Even
208 // selecting on a letter would make that text followed by Enter grant
209 // access, so only deliberate navigation changes the selected option.
210 match shell_key_routing::route(Focus::Modal(ModalKind::Elevation), &key) {
211 Some(ShellBindingId::ElevationUp) => {
212 self.select_prev();
213 ViewAction::None
214 }
215 Some(ShellBindingId::ElevationDown) => {
216 self.select_next();
217 ViewAction::None
218 }
219 Some(ShellBindingId::ElevationConfirm) => {
220 self.emit_decision(self.current_option().clone())
221 }
222 Some(ShellBindingId::ElevationAbort) => self.emit_decision(ElevationOption::Abort),
223 _ => ViewAction::None,
224 }
225 }
226
227 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
228 match mouse.kind {
229 MouseEventKind::ScrollUp => {
230 self.select_prev();
231 ViewAction::None
232 }
233 MouseEventKind::ScrollDown => {
234 self.select_next();
235 ViewAction::None
236 }
237 MouseEventKind::Down(MouseButton::Left) => {
238 let clicked = self.row_hitboxes.borrow().iter().position(|rect| {
239 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
240 });
241 if let Some(index) = clicked {
242 return self.emit_decision(self.request.options[index].clone());
243 }
244 ViewAction::None
245 }
246 _ => ViewAction::None,
247 }
248 }
249
250 fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
251 let elevation_widget = ElevationWidget::new_with_hitboxes(
252 &self.request,
253 self.selected,
254 self.locale,
255 &self.row_hitboxes,
256 );
257 elevation_widget.render(area, buf);
258 }
259 }
260
260 lines RUST