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