返回 DeepSeek-TUI-2026
approval.rs
根目录 / crates / tui / src / tui / approval.rs
1 //! Tool approval system for `DeepSeek` CLI.
2 //!
3 //! Hosts the [`ApprovalRequest`] / [`ApprovalView`] pair the engine asks
4 //! the TUI to present whenever a tool needs human approval, plus the
5 //! sandbox elevation flow ([`ElevationRequest`] / [`ElevationView`]) that
6 //! follows a sandbox denial.
7 //!
8 //! ## v0.6.7: Codex-style takeover with stakes-based variants (#129)
9 //!
10 //! The modal now renders as a full-screen takeover (calm centered card
11 //! against the transcript area) and routes each request to one of two
12 //! stakes-based variants:
13 //!
14 //! - **Benign** (`RiskLevel::Benign`) — read-only ops, MCP discovery,
15 //! query-only network. A single `Enter` / `1` / `y` approves once;
16 //! `2` / `a` approves for the session.
17 //! - **Destructive** (`RiskLevel::Destructive`) — file writes, shell,
18 //! patches, MCP actions, unclassified tools, and any "fetch arbitrary
19 //! content" surface. The first approve press *stages* a decision and
20 //! the second matching press commits — muscle-memory `Enter` cannot
21 //! accidentally land on an approval. Any non-approve key clears the
22 //! staging and keeps the user in selection mode.
23 //!
24 //! The decision events emitted upstream are unchanged
25 //! (`ViewEvent::ApprovalDecision`), so `ui.rs` and the engine handle
26 //! both variants without modification. Auto-approve / YOLO bypasses
27 //! happen *before* the view is constructed (see `tui/ui.rs`); this
28 //! module always assumes the user is being asked.
29
30 use crate::sandbox::SandboxPolicy;
31 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
32 use crate::tui::widgets::{ApprovalWidget, ElevationWidget, Renderable};
33 use crossterm::event::{KeyCode, KeyEvent};
34 use serde_json::Value;
35 use std::path::{Path, PathBuf};
36 use std::time::{Duration, Instant};
37
38 /// Determines when tool executions require user approval
39 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40 pub enum ApprovalMode {
41 /// Auto-approve all tools (YOLO mode / --yolo flag)
42 Auto,
43 /// Suggest approval for non-safe tools (non-YOLO modes)
44 #[default]
45 Suggest,
46 /// Never execute tools requiring approval
47 Never,
48 }
49
50 impl ApprovalMode {
51 pub fn label(self) -> &'static str {
52 match self {
53 ApprovalMode::Auto => "AUTO",
54 ApprovalMode::Suggest => "SUGGEST",
55 ApprovalMode::Never => "NEVER",
56 }
57 }
58
59 pub fn from_config_value(value: &str) -> Option<Self> {
60 match value.trim().to_ascii_lowercase().as_str() {
61 "auto" => Some(ApprovalMode::Auto),
62 "suggest" | "suggested" | "on-request" | "untrusted" => Some(ApprovalMode::Suggest),
63 "never" | "deny" | "denied" => Some(ApprovalMode::Never),
64 _ => None,
65 }
66 }
67 }
68
69 /// User's decision for a pending approval
70 #[derive(Debug, Clone, PartialEq, Eq)]
71 pub enum ReviewDecision {
72 /// Execute this tool once
73 Approved,
74 /// Approve and don't ask again for this tool type this session
75 ApprovedForSession,
76 /// Reject the tool execution
77 Denied,
78 /// Abort the entire turn
79 Abort,
80 }
81
82 /// Categorizes tools by cost/risk level
83 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
84 pub enum ToolCategory {
85 /// Free, read-only operations (`list_dir`, `read_file`, todo_*)
86 Safe,
87 /// File modifications (`write_file`, `edit_file`)
88 FileWrite,
89 /// Shell execution (`exec_shell`)
90 Shell,
91 /// Network-oriented built-in tools
92 Network,
93 /// Read-only MCP discovery and resource access
94 McpRead,
95 /// MCP actions that may change remote state
96 McpAction,
97 /// Unknown or unclassified tool surface
98 Unknown,
99 }
100
101 /// Stakes-based variant for the takeover modal.
102 ///
103 /// `RiskLevel::Benign` lets a single keystroke commit the approval.
104 /// `RiskLevel::Destructive` requires an explicit second confirmation
105 /// keypress so muscle-memory `Enter` never lands on an irreversible op.
106 ///
107 /// Routing rules live in [`classify_risk`] — when in doubt, route to
108 /// `Destructive`.
109 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
110 pub enum RiskLevel {
111 Benign,
112 Destructive,
113 }
114
115 /// Request for user approval of a tool execution
116 #[derive(Debug, Clone)]
117 pub struct ApprovalRequest {
118 /// Unique ID for this tool use
119 pub id: String,
120 /// Tool being executed
121 pub tool_name: String,
122 /// Human-readable tool description from the engine
123 pub description: String,
124 /// Tool category
125 pub category: ToolCategory,
126 /// Stakes-based routing for the takeover modal
127 pub risk: RiskLevel,
128 /// Derived impact summary for the approval prompt
129 pub impacts: Vec<String>,
130 /// Tool parameters (for display)
131 pub params: Value,
132 /// Fingerprint key for per‑call approval caching (§5.A).
133 pub approval_key: String,
134 }
135
136 impl ApprovalRequest {
137 pub fn new(
138 id: &str,
139 tool_name: &str,
140 description: &str,
141 params: &Value,
142 approval_key: &str,
143 ) -> Self {
144 let category = get_tool_category(tool_name);
145 let risk = classify_risk(tool_name, category, params);
146
147 Self {
148 id: id.to_string(),
149 tool_name: tool_name.to_string(),
150 description: description.to_string(),
151 category,
152 risk,
153 impacts: build_impact_summary(tool_name, category, params),
154 params: params.clone(),
155 approval_key: approval_key.to_string(),
156 }
157 }
158
159 /// Format parameters for display (truncated)
160 pub fn params_display(&self) -> String {
161 let truncated = truncate_params_value(&self.params, 200);
162 serde_json::to_string(&truncated).unwrap_or_else(|_| truncated.to_string())
163 }
164 }
165
166 /// Get the category for a tool by name
167 pub fn get_tool_category(name: &str) -> ToolCategory {
168 if matches!(name, "write_file" | "edit_file" | "apply_patch") {
169 ToolCategory::FileWrite
170 } else if matches!(name, "web_run" | "web_search" | "fetch_url") {
171 ToolCategory::Network
172 } else if name == "exec_shell" {
173 ToolCategory::Shell
174 } else if name.starts_with("list_mcp_")
175 || name.starts_with("read_mcp_")
176 || name.starts_with("get_mcp_")
177 {
178 ToolCategory::McpRead
179 } else if name.starts_with("mcp_") {
180 ToolCategory::McpAction
181 } else if matches!(
182 name,
183 "read_file"
184 | "list_dir"
185 | "todo_write"
186 | "todo_read"
187 | "note"
188 | "update_plan"
189 | "search"
190 | "file_search"
191 | "project"
192 | "diagnostics"
193 ) || name.starts_with("read_")
194 || name.starts_with("list_")
195 || name.starts_with("get_")
196 {
197 ToolCategory::Safe
198 } else {
199 ToolCategory::Unknown
200 }
201 }
202
203 /// Decide the stakes variant for an approval request.
204 ///
205 /// The bias is conservative: a category we don't recognise routes to
206 /// `Destructive`, and any shell command that `command_safety` flags as
207 /// `Dangerous` is forced to `Destructive` even when the rest of the
208 /// request looks calm. The split lets the modal swap muscle-memory
209 /// approval for an explicit two-key confirmation on anything that can
210 /// touch state outside this turn.
211 #[must_use]
212 pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel {
213 match category {
214 // Read paths and discovery — never staged.
215 ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign,
216 // Query-only network is benign; opening a URL pulls arbitrary
217 // remote content, so it stays destructive.
218 ToolCategory::Network => match tool_name {
219 "web_search" | "web_run" => RiskLevel::Benign,
220 _ => RiskLevel::Destructive,
221 },
222 // Shell is always destructive. We probe command_safety for
223 // shape so a future routing tweak (say, pure-readonly `ls`
224 // staying benign) lands here without a second pass.
225 ToolCategory::Shell => {
226 if let Some(cmd) = params.get("command").and_then(Value::as_str) {
227 let _ = crate::command_safety::analyze_command(cmd);
228 }
229 RiskLevel::Destructive
230 }
231 // File writes, MCP actions, unclassified surfaces — all
232 // require explicit confirmation.
233 ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => {
234 RiskLevel::Destructive
235 }
236 }
237 }
238
239 fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option<String> {
240 let Value::Object(map) = params else {
241 return None;
242 };
243
244 for key in keys {
245 let Some(value) = map.get(*key) else {
246 continue;
247 };
248 match value {
249 Value::String(text) => return Some(truncate_string_value(text, max_len)),
250 Value::Number(number) => return Some(number.to_string()),
251 Value::Bool(flag) => return Some(flag.to_string()),
252 Value::Array(items) if !items.is_empty() => {
253 let preview = items
254 .iter()
255 .take(3)
256 .map(|item| match item {
257 Value::String(text) => truncate_string_value(text, max_len / 2),
258 other => truncate_string_value(&other.to_string(), max_len / 2),
259 })
260 .collect::<Vec<_>>()
261 .join(", ");
262 return Some(truncate_string_value(&preview, max_len));
263 }
264 other => return Some(truncate_string_value(&other.to_string(), max_len)),
265 }
266 }
267
268 None
269 }
270
271 fn mcp_server_hint(tool_name: &str) -> Option<String> {
272 let remainder = tool_name.strip_prefix("mcp_")?;
273 let (server, _) = remainder.split_once('_')?;
274 if server.is_empty() {
275 None
276 } else {
277 Some(server.to_string())
278 }
279 }
280
281 fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) -> Vec<String> {
282 match category {
283 ToolCategory::Safe => {
284 let mut impacts = vec!["Read-only operation.".to_string()];
285 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
286 impacts.push(format!("Reads: {path}"));
287 }
288 impacts
289 }
290 ToolCategory::FileWrite => {
291 let mut impacts =
292 vec!["Writes files in the workspace or an approved write scope.".to_string()];
293 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
294 impacts.push(format!("Writes: {path}"));
295 }
296 impacts
297 }
298 ToolCategory::Shell => {
299 let mut impacts = vec!["Executes a shell command.".to_string()];
300 if let Some(command) = param_preview(params, &["cmd", "command"], 96) {
301 impacts.push(format!("Command: {command}"));
302 }
303 if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 72) {
304 impacts.push(format!("Working dir: {workdir}"));
305 }
306 impacts
307 }
308 ToolCategory::Network => {
309 let mut impacts = vec!["May reach network services or remote content.".to_string()];
310 if let Some(target) =
311 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
312 {
313 impacts.push(format!("Target: {target}"));
314 }
315 impacts
316 }
317 ToolCategory::McpRead => {
318 let mut impacts =
319 vec!["Reads from an MCP server without an obvious local write.".to_string()];
320 if let Some(server) = mcp_server_hint(tool_name) {
321 impacts.push(format!("Server: {server}"));
322 }
323 impacts
324 }
325 ToolCategory::McpAction => {
326 let mut impacts =
327 vec!["Calls an MCP server action that may have side effects.".to_string()];
328 if let Some(server) = mcp_server_hint(tool_name) {
329 impacts.push(format!("Server: {server}"));
330 }
331 impacts
332 }
333 ToolCategory::Unknown => {
334 let mut impacts = vec![
335 "Tool is not classified. Review params carefully before approving.".to_string(),
336 ];
337 if let Some(target) = param_preview(
338 params,
339 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
340 96,
341 ) {
342 impacts.push(format!("Primary input: {target}"));
343 }
344 impacts
345 }
346 }
347 }
348
349 /// Indices into the option list shared by both variants. Visible to
350 /// the widget module so it can render the staged-confirmation banner
351 /// without re-deriving the variant from the request.
352 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
353 pub enum ApprovalOption {
354 ApproveOnce,
355 ApproveAlways,
356 Deny,
357 Abort,
358 }
359
360 impl ApprovalOption {
361 const ORDER: [ApprovalOption; 4] = [
362 ApprovalOption::ApproveOnce,
363 ApprovalOption::ApproveAlways,
364 ApprovalOption::Deny,
365 ApprovalOption::Abort,
366 ];
367
368 fn from_index(idx: usize) -> ApprovalOption {
369 Self::ORDER.get(idx).copied().unwrap_or(Self::Abort)
370 }
371
372 fn index(self) -> usize {
373 Self::ORDER
374 .iter()
375 .position(|o| *o == self)
376 .unwrap_or(Self::ORDER.len() - 1)
377 }
378
379 fn decision(self) -> ReviewDecision {
380 match self {
381 ApprovalOption::ApproveOnce => ReviewDecision::Approved,
382 ApprovalOption::ApproveAlways => ReviewDecision::ApprovedForSession,
383 ApprovalOption::Deny => ReviewDecision::Denied,
384 ApprovalOption::Abort => ReviewDecision::Abort,
385 }
386 }
387
388 /// Whether this option needs an explicit second-key confirmation in
389 /// the destructive variant. Deny/Abort are never staged.
390 fn requires_confirm(self, risk: RiskLevel) -> bool {
391 matches!(risk, RiskLevel::Destructive)
392 && matches!(
393 self,
394 ApprovalOption::ApproveOnce | ApprovalOption::ApproveAlways
395 )
396 }
397 }
398
399 /// Approval overlay state managed by the modal view stack
400 #[derive(Debug, Clone)]
401 pub struct ApprovalView {
402 request: ApprovalRequest,
403 selected: usize,
404 /// When `Some`, the destructive variant has staged this approval and
405 /// is waiting for the user to press the same key (or `Enter`) again.
406 /// Any other key clears the staging.
407 pending_confirm: Option<ApprovalOption>,
408 timeout: Option<Duration>,
409 requested_at: Instant,
410 }
411
412 impl ApprovalView {
413 pub fn new(request: ApprovalRequest) -> Self {
414 Self {
415 request,
416 selected: 0,
417 pending_confirm: None,
418 timeout: None,
419 requested_at: Instant::now(),
420 }
421 }
422
423 fn select_prev(&mut self) {
424 self.selected = self.selected.saturating_sub(1);
425 // Moving the selection abandons any staged confirmation; the
426 // user is reconsidering.
427 self.pending_confirm = None;
428 }
429
430 fn select_next(&mut self) {
431 self.selected = (self.selected + 1).min(ApprovalOption::ORDER.len() - 1);
432 self.pending_confirm = None;
433 }
434
435 fn current_option(&self) -> ApprovalOption {
436 ApprovalOption::from_index(self.selected)
437 }
438
439 /// Test-only accessor — the widget reads decisions through
440 /// `commit_or_stage` instead of polling.
441 #[cfg(test)]
442 fn current_decision(&self) -> ReviewDecision {
443 self.current_option().decision()
444 }
445
446 /// Selected option for the renderer (used by the widget tests too).
447 pub fn selected(&self) -> usize {
448 self.selected
449 }
450
451 /// Risk level for the renderer's accent picking.
452 #[cfg(test)]
453 pub fn risk(&self) -> RiskLevel {
454 self.request.risk
455 }
456
457 /// The staged option, if any. `None` in the benign variant or when
458 /// no approve key has been pressed yet.
459 pub(crate) fn pending_confirm(&self) -> Option<ApprovalOption> {
460 self.pending_confirm
461 }
462
463 /// Try to commit (or stage) the given option respecting the
464 /// variant's confirmation policy. Returns the action the modal
465 /// stack should apply.
466 fn commit_or_stage(&mut self, option: ApprovalOption) -> ViewAction {
467 if option.requires_confirm(self.request.risk) {
468 // Two-step destructive flow: first press stages, second
469 // press of the same option commits.
470 if self.pending_confirm == Some(option) {
471 self.pending_confirm = None;
472 return self.emit_decision(option.decision(), false);
473 }
474 self.pending_confirm = Some(option);
475 self.selected = option.index();
476 return ViewAction::None;
477 }
478 // Benign variant or non-approve options commit immediately.
479 self.pending_confirm = None;
480 self.emit_decision(option.decision(), false)
481 }
482
483 fn emit_decision(&self, decision: ReviewDecision, timed_out: bool) -> ViewAction {
484 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
485 tool_id: self.request.id.clone(),
486 tool_name: self.request.tool_name.clone(),
487 decision,
488 timed_out,
489 approval_key: self.request.approval_key.clone(),
490 })
491 }
492
493 fn emit_params_pager(&self) -> ViewAction {
494 let content = serde_json::to_string_pretty(&self.request.params)
495 .unwrap_or_else(|_| self.request.params.to_string());
496 ViewAction::Emit(ViewEvent::OpenTextPager {
497 title: format!("Tool Params: {}", self.request.tool_name),
498 content,
499 })
500 }
501
502 fn is_timed_out(&self) -> bool {
503 match self.timeout {
504 Some(timeout) => self.requested_at.elapsed() >= timeout,
505 None => false,
506 }
507 }
508 }
509
510 impl ModalView for ApprovalView {
511 fn kind(&self) -> ModalKind {
512 ModalKind::Approval
513 }
514
515 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
516 self
517 }
518
519 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
520 match key.code {
521 KeyCode::Up | KeyCode::Char('k') => {
522 self.select_prev();
523 ViewAction::None
524 }
525 KeyCode::Down | KeyCode::Char('j') => {
526 self.select_next();
527 ViewAction::None
528 }
529 KeyCode::Enter => self.commit_or_stage(self.current_option()),
530 // Direct shortcuts; '1' / '2' map to the first two options
531 // so a numeric pad still works for benign approve flows.
532 KeyCode::Char('y') | KeyCode::Char('1') => {
533 self.commit_or_stage(ApprovalOption::ApproveOnce)
534 }
535 KeyCode::Char('a') | KeyCode::Char('2') => {
536 self.commit_or_stage(ApprovalOption::ApproveAlways)
537 }
538 KeyCode::Char('n') | KeyCode::Char('d') | KeyCode::Char('3') => {
539 self.commit_or_stage(ApprovalOption::Deny)
540 }
541 KeyCode::Char('v') | KeyCode::Char('V') => {
542 self.pending_confirm = None;
543 self.emit_params_pager()
544 }
545 KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false),
546 _ => {
547 // Any unrecognised key cancels a staged confirmation —
548 // the user is no longer aiming at "approve".
549 self.pending_confirm = None;
550 ViewAction::None
551 }
552 }
553 }
554
555 fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
556 let approval_widget = ApprovalWidget::new(&self.request, self);
557 approval_widget.render(area, buf);
558 }
559
560 fn tick(&mut self) -> ViewAction {
561 if self.is_timed_out() {
562 return self.emit_decision(ReviewDecision::Denied, true);
563 }
564 ViewAction::None
565 }
566 }
567
568 fn truncate_params_value(value: &Value, max_len: usize) -> Value {
569 match value {
570 Value::Object(map) => {
571 let truncated = map
572 .iter()
573 .map(|(key, val)| (key.clone(), truncate_params_value(val, max_len)))
574 .collect();
575 Value::Object(truncated)
576 }
577 Value::Array(items) => {
578 let truncated_items = items
579 .iter()
580 .map(|val| truncate_params_value(val, max_len))
581 .collect();
582 Value::Array(truncated_items)
583 }
584 Value::String(text) => Value::String(truncate_string_value(text, max_len)),
585 other => {
586 let rendered = other.to_string();
587 if rendered.chars().count() > max_len {
588 Value::String(truncate_string_value(&rendered, max_len))
589 } else {
590 other.clone()
591 }
592 }
593 }
594 }
595
596 fn truncate_string_value(value: &str, max_len: usize) -> String {
597 if value.chars().count() <= max_len {
598 return value.to_string();
599 }
600 let truncated: String = value.chars().take(max_len).collect();
601 format!("{truncated}...")
602 }
603
604 // ============================================================================
605 // Sandbox Elevation Flow
606 // ============================================================================
607
608 /// Options for elevating sandbox permissions after a denial.
609 #[derive(Debug, Clone, PartialEq, Eq)]
610 pub enum ElevationOption {
611 /// Add network access to the sandbox policy.
612 WithNetwork,
613 /// Add write access to specific paths.
614 WithWriteAccess(Vec<PathBuf>),
615 /// Remove sandbox restrictions entirely (dangerous).
616 FullAccess,
617 /// Abort the tool execution.
618 Abort,
619 }
620
621 impl ElevationOption {
622 /// Get the display label for this option.
623 pub fn label(&self) -> &'static str {
624 match self {
625 ElevationOption::WithNetwork => "Allow outbound network",
626 ElevationOption::WithWriteAccess(_) => "Allow extra write access",
627 ElevationOption::FullAccess => "Full access (filesystem + network)",
628 ElevationOption::Abort => "Abort",
629 }
630 }
631
632 /// Get a short description.
633 pub fn description(&self) -> &'static str {
634 match self {
635 ElevationOption::WithNetwork => {
636 "Retry this tool call with outbound network access for downloads and HTTP requests"
637 }
638 ElevationOption::WithWriteAccess(_) => {
639 "Retry this tool call with additional writable filesystem scope"
640 }
641 ElevationOption::FullAccess => {
642 "Retry without sandbox limits; grants unrestricted filesystem and network access"
643 }
644 ElevationOption::Abort => "Cancel this tool execution",
645 }
646 }
647
648 /// Convert to a sandbox policy.
649 pub fn to_policy(&self, base_cwd: &Path) -> SandboxPolicy {
650 match self {
651 ElevationOption::WithNetwork => SandboxPolicy::workspace_with_network(),
652 ElevationOption::WithWriteAccess(paths) => {
653 let mut roots = paths.clone();
654 roots.push(base_cwd.to_path_buf());
655 SandboxPolicy::workspace_with_roots(roots, false)
656 }
657 ElevationOption::FullAccess => SandboxPolicy::DangerFullAccess,
658 ElevationOption::Abort => SandboxPolicy::default(), // Won't be used
659 }
660 }
661 }
662
663 /// Request for user decision after a sandbox denial.
664 #[derive(Debug, Clone)]
665 pub struct ElevationRequest {
666 /// The tool ID that was blocked.
667 pub tool_id: String,
668 /// The tool name.
669 pub tool_name: String,
670 /// The command that was blocked (if shell).
671 pub command: Option<String>,
672 /// The reason for denial (from sandbox).
673 pub denial_reason: String,
674 /// Available elevation options.
675 pub options: Vec<ElevationOption>,
676 }
677
678 impl ElevationRequest {
679 /// Create a new elevation request for a shell command.
680 pub fn for_shell(
681 tool_id: &str,
682 command: &str,
683 denial_reason: &str,
684 blocked_network: bool,
685 blocked_write: bool,
686 ) -> Self {
687 let mut options = Vec::new();
688
689 if blocked_network {
690 options.push(ElevationOption::WithNetwork);
691 }
692 if blocked_write {
693 options.push(ElevationOption::WithWriteAccess(vec![]));
694 }
695 options.push(ElevationOption::FullAccess);
696 options.push(ElevationOption::Abort);
697
698 Self {
699 tool_id: tool_id.to_string(),
700 tool_name: "exec_shell".to_string(),
701 command: Some(command.to_string()),
702 denial_reason: denial_reason.to_string(),
703 options,
704 }
705 }
706
707 /// Create a generic elevation request.
708 #[allow(dead_code)]
709 pub fn generic(tool_id: &str, tool_name: &str, denial_reason: &str) -> Self {
710 Self {
711 tool_id: tool_id.to_string(),
712 tool_name: tool_name.to_string(),
713 command: None,
714 denial_reason: denial_reason.to_string(),
715 options: vec![
716 ElevationOption::WithNetwork,
717 ElevationOption::FullAccess,
718 ElevationOption::Abort,
719 ],
720 }
721 }
722 }
723
724 /// Elevation overlay state managed by the modal view stack.
725 #[derive(Debug, Clone)]
726 pub struct ElevationView {
727 request: ElevationRequest,
728 selected: usize,
729 }
730
731 impl ElevationView {
732 pub fn new(request: ElevationRequest) -> Self {
733 Self {
734 request,
735 selected: 0,
736 }
737 }
738
739 fn select_prev(&mut self) {
740 self.selected = self.selected.saturating_sub(1);
741 }
742
743 fn select_next(&mut self) {
744 let max = self.request.options.len().saturating_sub(1);
745 self.selected = (self.selected + 1).min(max);
746 }
747
748 fn current_option(&self) -> &ElevationOption {
749 &self.request.options[self.selected]
750 }
751
752 fn emit_decision(&self, option: ElevationOption) -> ViewAction {
753 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
754 tool_id: self.request.tool_id.clone(),
755 tool_name: self.request.tool_name.clone(),
756 option,
757 })
758 }
759
760 /// Get the request for rendering.
761 #[allow(dead_code)]
762 pub fn request(&self) -> &ElevationRequest {
763 &self.request
764 }
765
766 /// Get the currently selected index.
767 #[allow(dead_code)]
768 pub fn selected(&self) -> usize {
769 self.selected
770 }
771 }
772
773 impl ModalView for ElevationView {
774 fn kind(&self) -> ModalKind {
775 ModalKind::Elevation
776 }
777
778 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
779 self
780 }
781
782 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
783 match key.code {
784 KeyCode::Up | KeyCode::Char('k') => {
785 self.select_prev();
786 ViewAction::None
787 }
788 KeyCode::Down | KeyCode::Char('j') => {
789 self.select_next();
790 ViewAction::None
791 }
792 KeyCode::Enter => self.emit_decision(self.current_option().clone()),
793 KeyCode::Char('n') => self.emit_decision(ElevationOption::WithNetwork),
794 KeyCode::Char('w') => {
795 // Find the write access option if available
796 for opt in &self.request.options {
797 if matches!(opt, ElevationOption::WithWriteAccess(_)) {
798 return self.emit_decision(opt.clone());
799 }
800 }
801 ViewAction::None
802 }
803 KeyCode::Char('f') => self.emit_decision(ElevationOption::FullAccess),
804 KeyCode::Esc | KeyCode::Char('a') => self.emit_decision(ElevationOption::Abort),
805 _ => ViewAction::None,
806 }
807 }
808
809 fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
810 let elevation_widget = ElevationWidget::new(&self.request, self.selected);
811 elevation_widget.render(area, buf);
812 }
813 }
814
815 // ============================================================================
816 // Tests
817 // ============================================================================
818
819 #[cfg(test)]
820 mod tests {
821 use super::*;
822 use crossterm::event::{KeyCode, KeyModifiers};
823 use serde_json::json;
824
825 fn create_key_event(code: KeyCode) -> KeyEvent {
826 KeyEvent {
827 code,
828 modifiers: KeyModifiers::empty(),
829 kind: crossterm::event::KeyEventKind::Press,
830 state: crossterm::event::KeyEventState::NONE,
831 }
832 }
833
834 fn benign_request() -> ApprovalRequest {
835 ApprovalRequest::new(
836 "test-id",
837 "read_file",
838 "Read a file from disk",
839 &json!({"path": "src/main.rs"}),
840 "tool:read_file",
841 )
842 }
843
844 fn destructive_request() -> ApprovalRequest {
845 ApprovalRequest::new(
846 "test-id",
847 "write_file",
848 "Write a file to disk",
849 &json!({"path": "src/main.rs", "content": "test"}),
850 "tool:write_file",
851 )
852 }
853
854 // ========================================================================
855 // Tool Category Tests
856 // ========================================================================
857
858 #[test]
859 fn test_get_tool_category_safe_tools() {
860 assert_eq!(get_tool_category("read_file"), ToolCategory::Safe);
861 assert_eq!(get_tool_category("list_dir"), ToolCategory::Safe);
862 assert_eq!(get_tool_category("todo_write"), ToolCategory::Safe);
863 assert_eq!(get_tool_category("todo_read"), ToolCategory::Safe);
864 assert_eq!(get_tool_category("note"), ToolCategory::Safe);
865 assert_eq!(get_tool_category("update_plan"), ToolCategory::Safe);
866 }
867
868 #[test]
869 fn test_get_tool_category_file_write_tools() {
870 assert_eq!(get_tool_category("write_file"), ToolCategory::FileWrite);
871 assert_eq!(get_tool_category("edit_file"), ToolCategory::FileWrite);
872 assert_eq!(get_tool_category("apply_patch"), ToolCategory::FileWrite);
873 }
874
875 #[test]
876 fn test_get_tool_category_shell_tools() {
877 assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell);
878 assert_eq!(
879 get_tool_category("mcp_linear_save_issue"),
880 ToolCategory::McpAction
881 );
882 assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::McpRead);
883 }
884
885 #[test]
886 fn test_get_tool_category_unknown_tools_need_review() {
887 assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Unknown);
888 }
889
890 // ========================================================================
891 // Risk Routing Tests (#129)
892 // ========================================================================
893
894 #[test]
895 fn risk_safe_categories_route_benign() {
896 let cat = ToolCategory::Safe;
897 assert_eq!(
898 classify_risk("read_file", cat, &json!({"path": "x"})),
899 RiskLevel::Benign
900 );
901 let cat = ToolCategory::McpRead;
902 assert_eq!(
903 classify_risk("list_mcp_tools", cat, &json!({})),
904 RiskLevel::Benign
905 );
906 }
907
908 #[test]
909 fn risk_query_only_network_is_benign_but_fetch_is_destructive() {
910 // web_search is read-only enough to skip the two-key dance.
911 let cat = ToolCategory::Network;
912 assert_eq!(
913 classify_risk("web_search", cat, &json!({"q": "rust"})),
914 RiskLevel::Benign
915 );
916 // fetch_url pulls arbitrary remote content; never staged.
917 assert_eq!(
918 classify_risk("fetch_url", cat, &json!({"url": "https://example.com"})),
919 RiskLevel::Destructive
920 );
921 }
922
923 #[test]
924 fn risk_writes_shell_mcp_action_unknown_route_destructive() {
925 for (name, cat) in [
926 ("write_file", ToolCategory::FileWrite),
927 ("edit_file", ToolCategory::FileWrite),
928 ("apply_patch", ToolCategory::FileWrite),
929 ("exec_shell", ToolCategory::Shell),
930 ("mcp_linear_save_issue", ToolCategory::McpAction),
931 ("totally_new_tool", ToolCategory::Unknown),
932 ] {
933 assert_eq!(
934 classify_risk(name, cat, &json!({})),
935 RiskLevel::Destructive,
936 "expected {name:?} to be Destructive",
937 );
938 }
939 }
940
941 #[test]
942 fn risk_dangerous_shell_command_stays_destructive() {
943 // command_safety would flag this as Dangerous; classify_risk
944 // already routes Shell to Destructive. The check exists so a
945 // future attempt to relax shell to Benign cannot smuggle this
946 // through unexamined.
947 let cat = ToolCategory::Shell;
948 assert_eq!(
949 classify_risk("exec_shell", cat, &json!({"command": "rm -rf /"})),
950 RiskLevel::Destructive
951 );
952 }
953
954 // ========================================================================
955 // ApprovalRequest Tests
956 // ========================================================================
957
958 #[test]
959 fn test_approval_request_new() {
960 let params = json!({"path": "src/main.rs", "content": "test"});
961 let request = ApprovalRequest::new(
962 "test-id",
963 "write_file",
964 "Write a file to disk",
965 &params,
966 "test_key",
967 );
968
969 assert_eq!(request.id, "test-id");
970 assert_eq!(request.tool_name, "write_file");
971 assert_eq!(request.category, ToolCategory::FileWrite);
972 assert_eq!(request.risk, RiskLevel::Destructive);
973 assert_eq!(request.params, params);
974 }
975
976 #[test]
977 fn test_approval_request_params_display_truncates() {
978 let long_content = "x".repeat(300);
979 let params = json!({"path": "src/main.rs", "content": long_content});
980 let request = ApprovalRequest::new(
981 "test-id",
982 "write_file",
983 "Write a file to disk",
984 &params,
985 "test_key",
986 );
987
988 let display = request.params_display();
989 assert!(display.len() < 250);
990 assert!(display.contains("src/main.rs"));
991 }
992
993 #[test]
994 fn test_approval_request_params_display_short() {
995 let params = json!({"path": "src/main.rs"});
996 let request = ApprovalRequest::new(
997 "test-id",
998 "read_file",
999 "Read a file from disk",
1000 &params,
1001 "test_key",
1002 );
1003
1004 let display = request.params_display();
1005 assert!(display.contains("src/main.rs"));
1006 }
1007
1008 #[test]
1009 fn test_approval_request_derives_impact_summary() {
1010 let params = json!({"cmd": "cargo test", "workdir": "/tmp/project"});
1011 let request = ApprovalRequest::new(
1012 "test-id",
1013 "exec_shell",
1014 "Run a shell command",
1015 &params,
1016 "test_key",
1017 );
1018
1019 assert_eq!(request.category, ToolCategory::Shell);
1020 assert!(
1021 request
1022 .impacts
1023 .iter()
1024 .any(|line| line.contains("Executes a shell command"))
1025 );
1026 assert!(
1027 request
1028 .impacts
1029 .iter()
1030 .any(|line| line.contains("cargo test"))
1031 );
1032 }
1033
1034 // ========================================================================
1035 // ApprovalView Tests — Benign Variant (single-key approve)
1036 // ========================================================================
1037
1038 #[test]
1039 fn test_approval_view_initial_state() {
1040 let view = ApprovalView::new(benign_request());
1041 assert_eq!(view.selected, 0);
1042 assert!(view.timeout.is_none());
1043 assert_eq!(view.pending_confirm(), None);
1044 assert_eq!(view.risk(), RiskLevel::Benign);
1045 }
1046
1047 #[test]
1048 fn test_approval_view_navigation() {
1049 let mut view = ApprovalView::new(benign_request());
1050 assert_eq!(view.selected, 0);
1051
1052 view.select_next();
1053 assert_eq!(view.selected, 1);
1054 view.select_next();
1055 assert_eq!(view.selected, 2);
1056 view.select_next();
1057 assert_eq!(view.selected, 3);
1058
1059 // Should clamp at 3
1060 view.select_next();
1061 assert_eq!(view.selected, 3);
1062
1063 view.select_prev();
1064 assert_eq!(view.selected, 2);
1065 }
1066
1067 #[test]
1068 fn benign_y_one_step_approves() {
1069 let mut view = ApprovalView::new(benign_request());
1070 let action = view.handle_key(create_key_event(KeyCode::Char('y')));
1071 assert!(matches!(
1072 action,
1073 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1074 decision: ReviewDecision::Approved,
1075 ..
1076 })
1077 ));
1078 }
1079
1080 #[test]
1081 fn benign_one_key_approves_via_numeric_pad() {
1082 let mut view = ApprovalView::new(benign_request());
1083 let action = view.handle_key(create_key_event(KeyCode::Char('1')));
1084 assert!(matches!(
1085 action,
1086 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1087 decision: ReviewDecision::Approved,
1088 ..
1089 })
1090 ));
1091 }
1092
1093 #[test]
1094 fn benign_enter_approves_in_one_step() {
1095 let mut view = ApprovalView::new(benign_request());
1096 let action = view.handle_key(create_key_event(KeyCode::Enter));
1097 assert!(matches!(
1098 action,
1099 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1100 decision: ReviewDecision::Approved,
1101 ..
1102 })
1103 ));
1104 }
1105
1106 #[test]
1107 fn benign_a_two_approves_for_session() {
1108 let mut view = ApprovalView::new(benign_request());
1109 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
1110 assert!(matches!(
1111 action,
1112 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1113 decision: ReviewDecision::ApprovedForSession,
1114 ..
1115 })
1116 ));
1117
1118 let mut view = ApprovalView::new(benign_request());
1119 let action = view.handle_key(create_key_event(KeyCode::Char('2')));
1120 assert!(matches!(
1121 action,
1122 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1123 decision: ReviewDecision::ApprovedForSession,
1124 ..
1125 })
1126 ));
1127 }
1128
1129 #[test]
1130 fn benign_n_d_three_all_deny() {
1131 for code in [KeyCode::Char('n'), KeyCode::Char('d'), KeyCode::Char('3')] {
1132 let mut view = ApprovalView::new(benign_request());
1133 let action = view.handle_key(create_key_event(code));
1134 assert!(
1135 matches!(
1136 action,
1137 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1138 decision: ReviewDecision::Denied,
1139 ..
1140 })
1141 ),
1142 "expected Denied for {code:?}"
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn benign_esc_aborts() {
1149 let mut view = ApprovalView::new(benign_request());
1150 let action = view.handle_key(create_key_event(KeyCode::Esc));
1151 assert!(matches!(
1152 action,
1153 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1154 decision: ReviewDecision::Abort,
1155 ..
1156 })
1157 ));
1158 }
1159
1160 #[test]
1161 fn test_approval_view_enter_uses_selected_option() {
1162 let mut view = ApprovalView::new(benign_request());
1163
1164 // Navigate to index 2 (Denied)
1165 view.select_next();
1166 view.select_next();
1167 assert_eq!(view.selected, 2);
1168
1169 let action = view.handle_key(create_key_event(KeyCode::Enter));
1170 assert!(matches!(
1171 action,
1172 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1173 decision: ReviewDecision::Denied,
1174 ..
1175 })
1176 ));
1177 }
1178
1179 #[test]
1180 fn test_approval_view_navigation_keys() {
1181 let mut view = ApprovalView::new(benign_request());
1182
1183 view.handle_key(create_key_event(KeyCode::Up));
1184 assert_eq!(view.selected, 0); // clamped at 0
1185
1186 view.handle_key(create_key_event(KeyCode::Down));
1187 assert_eq!(view.selected, 1);
1188
1189 view.handle_key(create_key_event(KeyCode::Char('j')));
1190 assert_eq!(view.selected, 2);
1191
1192 view.handle_key(create_key_event(KeyCode::Char('k')));
1193 assert_eq!(view.selected, 1);
1194 }
1195
1196 #[test]
1197 fn test_approval_view_view_params() {
1198 let mut view = ApprovalView::new(benign_request());
1199 let action = view.handle_key(create_key_event(KeyCode::Char('v')));
1200 assert!(matches!(
1201 action,
1202 ViewAction::Emit(ViewEvent::OpenTextPager { .. })
1203 ));
1204
1205 let mut view = ApprovalView::new(benign_request());
1206 let action = view.handle_key(create_key_event(KeyCode::Char('V')));
1207 assert!(matches!(
1208 action,
1209 ViewAction::Emit(ViewEvent::OpenTextPager { .. })
1210 ));
1211 }
1212
1213 #[test]
1214 fn test_approval_view_current_decision_mapping() {
1215 let mut view = ApprovalView::new(benign_request());
1216
1217 view.selected = 0;
1218 assert_eq!(view.current_decision(), ReviewDecision::Approved);
1219 view.selected = 1;
1220 assert_eq!(view.current_decision(), ReviewDecision::ApprovedForSession);
1221 view.selected = 2;
1222 assert_eq!(view.current_decision(), ReviewDecision::Denied);
1223 view.selected = 3;
1224 assert_eq!(view.current_decision(), ReviewDecision::Abort);
1225 }
1226
1227 // ========================================================================
1228 // ApprovalView Tests — Destructive Variant (two-key confirm)
1229 // ========================================================================
1230
1231 #[test]
1232 fn destructive_request_routes_destructive() {
1233 let view = ApprovalView::new(destructive_request());
1234 assert_eq!(view.risk(), RiskLevel::Destructive);
1235 }
1236
1237 #[test]
1238 fn destructive_y_first_press_stages_then_second_commits() {
1239 let mut view = ApprovalView::new(destructive_request());
1240
1241 // First press stages — no decision emitted yet.
1242 let action = view.handle_key(create_key_event(KeyCode::Char('y')));
1243 assert!(matches!(action, ViewAction::None));
1244 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce));
1245
1246 // Second press of the same key commits.
1247 let action = view.handle_key(create_key_event(KeyCode::Char('y')));
1248 assert!(matches!(
1249 action,
1250 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1251 decision: ReviewDecision::Approved,
1252 ..
1253 })
1254 ));
1255 }
1256
1257 #[test]
1258 fn destructive_enter_first_press_stages_then_second_commits() {
1259 let mut view = ApprovalView::new(destructive_request());
1260
1261 // Selection starts at ApproveOnce — Enter stages.
1262 let action = view.handle_key(create_key_event(KeyCode::Enter));
1263 assert!(matches!(action, ViewAction::None));
1264 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce));
1265
1266 // Second Enter on the same selection commits.
1267 let action = view.handle_key(create_key_event(KeyCode::Enter));
1268 assert!(matches!(
1269 action,
1270 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1271 decision: ReviewDecision::Approved,
1272 ..
1273 })
1274 ));
1275 }
1276
1277 #[test]
1278 fn destructive_navigation_clears_staged_confirmation() {
1279 let mut view = ApprovalView::new(destructive_request());
1280
1281 view.handle_key(create_key_event(KeyCode::Char('y')));
1282 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce));
1283
1284 // Moving the selection abandons the staging.
1285 view.handle_key(create_key_event(KeyCode::Down));
1286 assert_eq!(view.pending_confirm(), None);
1287 }
1288
1289 #[test]
1290 fn destructive_unrelated_key_clears_staged_confirmation() {
1291 let mut view = ApprovalView::new(destructive_request());
1292
1293 view.handle_key(create_key_event(KeyCode::Char('y')));
1294 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce));
1295
1296 // A key with no mapped action clears the staging.
1297 let action = view.handle_key(create_key_event(KeyCode::Char('q')));
1298 assert!(matches!(action, ViewAction::None));
1299 assert_eq!(view.pending_confirm(), None);
1300 }
1301
1302 #[test]
1303 fn destructive_a_first_press_stages_then_second_commits_session() {
1304 let mut view = ApprovalView::new(destructive_request());
1305
1306 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
1307 assert!(matches!(action, ViewAction::None));
1308 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveAlways));
1309
1310 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
1311 assert!(matches!(
1312 action,
1313 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1314 decision: ReviewDecision::ApprovedForSession,
1315 ..
1316 })
1317 ));
1318 }
1319
1320 #[test]
1321 fn destructive_y_then_a_does_not_commit_either() {
1322 // Pressing 'y' then 'a' must NOT commit ApproveAlways — the
1323 // second key is a different option, so it re-stages instead.
1324 let mut view = ApprovalView::new(destructive_request());
1325
1326 let action = view.handle_key(create_key_event(KeyCode::Char('y')));
1327 assert!(matches!(action, ViewAction::None));
1328 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce));
1329
1330 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
1331 assert!(matches!(action, ViewAction::None));
1332 assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveAlways));
1333 }
1334
1335 #[test]
1336 fn destructive_deny_does_not_require_confirmation() {
1337 // Deny / Abort skip the two-key dance — the user is bailing.
1338 let mut view = ApprovalView::new(destructive_request());
1339 let action = view.handle_key(create_key_event(KeyCode::Char('n')));
1340 assert!(matches!(
1341 action,
1342 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1343 decision: ReviewDecision::Denied,
1344 ..
1345 })
1346 ));
1347 }
1348
1349 #[test]
1350 fn destructive_esc_aborts_immediately() {
1351 let mut view = ApprovalView::new(destructive_request());
1352 // Stage something first.
1353 view.handle_key(create_key_event(KeyCode::Char('y')));
1354 // Esc still aborts in one press.
1355 let action = view.handle_key(create_key_event(KeyCode::Esc));
1356 assert!(matches!(
1357 action,
1358 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1359 decision: ReviewDecision::Abort,
1360 ..
1361 })
1362 ));
1363 }
1364
1365 // ========================================================================
1366 // Render takeover smoke tests — keep the visual contract honest so a
1367 // future widget refactor cannot silently shrink back to a popup.
1368 // ========================================================================
1369
1370 fn render_lines(view: &ApprovalView, w: u16, h: u16) -> Vec<String> {
1371 use ratatui::buffer::Buffer;
1372 use ratatui::layout::Rect;
1373 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
1374 ModalView::render(view, Rect::new(0, 0, w, h), &mut buf);
1375 (0..buf.area.height)
1376 .map(|row| {
1377 (0..buf.area.width)
1378 .map(|col| buf[(col, row)].symbol().to_string())
1379 .collect::<String>()
1380 })
1381 .collect()
1382 }
1383
1384 #[test]
1385 fn render_benign_includes_review_badge_and_one_step_hint() {
1386 let view = ApprovalView::new(benign_request());
1387 let lines = render_lines(&view, 100, 40);
1388 let joined = lines.join("\n");
1389 assert!(joined.contains("REVIEW"), "missing REVIEW badge:\n{joined}");
1390 assert!(
1391 joined.contains("Single key approves"),
1392 "benign hint missing:\n{joined}"
1393 );
1394 assert!(joined.contains("read_file"));
1395 }
1396
1397 #[test]
1398 fn render_destructive_shows_warning_badge_and_two_step_hint() {
1399 let view = ApprovalView::new(destructive_request());
1400 let lines = render_lines(&view, 100, 40);
1401 let joined = lines.join("\n");
1402 assert!(
1403 joined.contains("DESTRUCTIVE"),
1404 "missing DESTRUCTIVE badge:\n{joined}"
1405 );
1406 assert!(
1407 joined.contains("Two keys to approve"),
1408 "destructive hint missing:\n{joined}"
1409 );
1410 assert!(joined.contains("write_file"));
1411 }
1412
1413 #[test]
1414 fn render_destructive_after_stage_shows_confirm_banner() {
1415 let mut view = ApprovalView::new(destructive_request());
1416 view.handle_key(create_key_event(KeyCode::Char('y')));
1417 let lines = render_lines(&view, 100, 40);
1418 let joined = lines.join("\n");
1419 assert!(
1420 joined.contains("Confirm destructive action"),
1421 "confirm banner missing:\n{joined}"
1422 );
1423 assert!(
1424 joined.contains("(staged)"),
1425 "stage marker missing:\n{joined}"
1426 );
1427 }
1428
1429 #[test]
1430 fn render_takeover_card_fills_most_of_area() {
1431 // The card should be wider than the old 65-cell popup whenever
1432 // the terminal can hold it; this guards against a regression
1433 // back to the centered popup.
1434 let view = ApprovalView::new(benign_request());
1435 let lines = render_lines(&view, 120, 40);
1436 // Find the widest non-blank rendered row.
1437 let widest = lines
1438 .iter()
1439 .map(|l| l.trim_end_matches(' ').len())
1440 .max()
1441 .unwrap_or(0);
1442 assert!(
1443 widest >= 80,
1444 "takeover card too narrow: widest row = {widest} cells"
1445 );
1446 }
1447
1448 // ========================================================================
1449 // ElevationView Tests
1450 // ========================================================================
1451
1452 #[test]
1453 fn test_elevation_view_initial_state() {
1454 let request =
1455 ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false);
1456 let view = ElevationView::new(request);
1457 assert_eq!(view.selected, 0);
1458 }
1459
1460 #[test]
1461 fn test_elevation_view_keybindings() {
1462 let request =
1463 ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true);
1464 let mut view = ElevationView::new(request);
1465
1466 let action = view.handle_key(create_key_event(KeyCode::Char('n')));
1467 assert!(matches!(
1468 action,
1469 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1470 option: ElevationOption::WithNetwork,
1471 ..
1472 })
1473 ));
1474
1475 let request =
1476 ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true);
1477 let mut view = ElevationView::new(request);
1478 let action = view.handle_key(create_key_event(KeyCode::Char('w')));
1479 assert!(matches!(
1480 action,
1481 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1482 option: ElevationOption::WithWriteAccess(_),
1483 ..
1484 })
1485 ));
1486
1487 let request =
1488 ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
1489 let mut view = ElevationView::new(request);
1490 let action = view.handle_key(create_key_event(KeyCode::Char('f')));
1491 assert!(matches!(
1492 action,
1493 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1494 option: ElevationOption::FullAccess,
1495 ..
1496 })
1497 ));
1498
1499 let request =
1500 ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
1501 let mut view = ElevationView::new(request);
1502 let action = view.handle_key(create_key_event(KeyCode::Esc));
1503 assert!(matches!(
1504 action,
1505 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1506 option: ElevationOption::Abort,
1507 ..
1508 })
1509 ));
1510
1511 let request =
1512 ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
1513 let mut view = ElevationView::new(request);
1514 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
1515 assert!(matches!(
1516 action,
1517 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1518 option: ElevationOption::Abort,
1519 ..
1520 })
1521 ));
1522 }
1523
1524 #[test]
1525 fn test_elevation_view_navigation() {
1526 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
1527 let mut view = ElevationView::new(request);
1528
1529 assert_eq!(view.selected, 0);
1530
1531 view.handle_key(create_key_event(KeyCode::Down));
1532 assert_eq!(view.selected, 1);
1533
1534 view.handle_key(create_key_event(KeyCode::Up));
1535 assert_eq!(view.selected, 0);
1536
1537 view.handle_key(create_key_event(KeyCode::Char('j')));
1538 assert_eq!(view.selected, 1);
1539
1540 view.handle_key(create_key_event(KeyCode::Char('k')));
1541 assert_eq!(view.selected, 0);
1542 }
1543
1544 #[test]
1545 fn test_elevation_view_enter_uses_selected_option() {
1546 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
1547 let mut view = ElevationView::new(request);
1548
1549 view.handle_key(create_key_event(KeyCode::Down));
1550 assert_eq!(view.selected, 1);
1551
1552 let action = view.handle_key(create_key_event(KeyCode::Enter));
1553 assert!(matches!(
1554 action,
1555 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1556 option: ElevationOption::FullAccess,
1557 ..
1558 })
1559 ));
1560 }
1561
1562 // ========================================================================
1563 // ElevationOption Tests
1564 // ========================================================================
1565
1566 #[test]
1567 fn test_elevation_option_labels() {
1568 assert_eq!(
1569 ElevationOption::WithNetwork.label(),
1570 "Allow outbound network"
1571 );
1572 assert_eq!(
1573 ElevationOption::FullAccess.label(),
1574 "Full access (filesystem + network)"
1575 );
1576 assert!(
1577 ElevationOption::WithWriteAccess(vec![])
1578 .label()
1579 .contains("write")
1580 );
1581 assert_eq!(ElevationOption::Abort.label(), "Abort");
1582 }
1583
1584 #[test]
1585 fn test_elevation_option_descriptions() {
1586 assert!(
1587 ElevationOption::WithNetwork
1588 .description()
1589 .contains("network")
1590 );
1591 assert!(
1592 ElevationOption::FullAccess
1593 .description()
1594 .contains("filesystem and network access")
1595 );
1596 assert!(ElevationOption::Abort.description().contains("Cancel"));
1597 }
1598
1599 #[test]
1600 fn test_elevation_option_to_policy() {
1601 let cwd = PathBuf::from("/tmp/test");
1602
1603 let policy = ElevationOption::WithNetwork.to_policy(&cwd);
1604 assert!(matches!(
1605 policy,
1606 SandboxPolicy::WorkspaceWrite {
1607 network_access: true,
1608 ..
1609 }
1610 ));
1611
1612 let policy = ElevationOption::FullAccess.to_policy(&cwd);
1613 assert!(matches!(policy, SandboxPolicy::DangerFullAccess));
1614
1615 let paths = vec![PathBuf::from("/tmp/test/src")];
1616 let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd);
1617 assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
1618 }
1619
1620 // ========================================================================
1621 // ElevationRequest Tests
1622 // ========================================================================
1623
1624 #[test]
1625 fn test_elevation_request_for_shell_with_network_block() {
1626 let request = ElevationRequest::for_shell(
1627 "test-id",
1628 "curl example.com",
1629 "network blocked",
1630 true,
1631 false,
1632 );
1633
1634 assert_eq!(request.tool_id, "test-id");
1635 assert_eq!(request.tool_name, "exec_shell");
1636 assert!(request.command.is_some());
1637 assert!(request.denial_reason.contains("network"));
1638 assert!(
1639 request
1640 .options
1641 .iter()
1642 .any(|o| matches!(o, ElevationOption::WithNetwork))
1643 );
1644 }
1645
1646 #[test]
1647 fn test_elevation_request_for_shell_with_write_block() {
1648 let request =
1649 ElevationRequest::for_shell("test-id", "rm -rf /tmp", "write blocked", false, true);
1650
1651 assert_eq!(request.tool_id, "test-id");
1652 assert!(
1653 request
1654 .options
1655 .iter()
1656 .any(|o| matches!(o, ElevationOption::WithWriteAccess(_)))
1657 );
1658 }
1659
1660 #[test]
1661 fn test_elevation_request_generic() {
1662 let request = ElevationRequest::generic("test-id", "some_tool", "permission denied");
1663
1664 assert_eq!(request.tool_id, "test-id");
1665 assert_eq!(request.tool_name, "some_tool");
1666 assert!(request.command.is_none());
1667 assert!(
1668 request
1669 .options
1670 .iter()
1671 .any(|o| matches!(o, ElevationOption::WithNetwork))
1672 );
1673 assert!(
1674 request
1675 .options
1676 .iter()
1677 .any(|o| matches!(o, ElevationOption::FullAccess))
1678 );
1679 assert!(
1680 request
1681 .options
1682 .iter()
1683 .any(|o| matches!(o, ElevationOption::Abort))
1684 );
1685 }
1686
1687 // ========================================================================
1688 // ApprovalMode Tests
1689 // ========================================================================
1690
1691 #[test]
1692 fn test_approval_mode_labels() {
1693 assert_eq!(ApprovalMode::Auto.label(), "AUTO");
1694 assert_eq!(ApprovalMode::Suggest.label(), "SUGGEST");
1695 assert_eq!(ApprovalMode::Never.label(), "NEVER");
1696 }
1697
1698 #[test]
1699 fn test_approval_mode_from_config_value_accepts_aliases() {
1700 assert_eq!(
1701 ApprovalMode::from_config_value("auto"),
1702 Some(ApprovalMode::Auto)
1703 );
1704 assert_eq!(
1705 ApprovalMode::from_config_value("on-request"),
1706 Some(ApprovalMode::Suggest)
1707 );
1708 assert_eq!(
1709 ApprovalMode::from_config_value("deny"),
1710 Some(ApprovalMode::Never)
1711 );
1712 assert_eq!(ApprovalMode::from_config_value("unknown"), None);
1713 }
1714 }
1715
1715 lines RUST