返回 CodeWhale
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 renders as a compact bottom-anchored approval card that preserves
11 //! transcript context 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 //! commands that are not proven read-only, patches, MCP actions,
19 //! unclassified tools, and any "fetch arbitrary content" surface.
20 //! The approval card keeps the destructive badge and
21 //! impact summary visible, then lets `Enter` commit the highlighted
22 //! option or `y` / `a` / `d` commit directly.
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::localization::{Locale, MessageId, tr};
31 use crate::sandbox::SandboxPolicy;
32 use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
33 use crate::tools::canonical_action::canonical_action_alias;
34 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
35 use crate::tui::widgets::{ApprovalWidget, ElevationWidget, Renderable};
36 use codewhale_config::ToolAskRule;
37 use codewhale_execpolicy::PermissionAction;
38 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
39 use ratatui::layout::Rect;
40 use serde_json::Value;
41 use std::borrow::Cow;
42 use std::cell::RefCell;
43 use std::path::{Path, PathBuf};
44 use std::time::{Duration, Instant};
45
46 pub mod policy;
47
48 pub use policy::{
49 ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes,
50 get_tool_category_for_call,
51 };
52
53 /// Determines when tool executions require user approval
54 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55 pub enum ApprovalMode {
56 /// Automatically review risky tool calls before deciding whether to ask.
57 Auto,
58 /// Bypass approvals entirely (YOLO mode / --yolo flag).
59 Bypass,
60 /// Suggest approval for non-safe tools (non-YOLO modes)
61 #[default]
62 Suggest,
63 /// Never execute tools requiring approval
64 Never,
65 }
66
67 impl ApprovalMode {
68 /// Shift+Tab permission cycle order (#0.8.68 M2).
69 pub const PERMISSION_CYCLE: [Self; 3] = [Self::Suggest, Self::Auto, Self::Bypass];
70
71 pub fn label(self) -> &'static str {
72 match self {
73 ApprovalMode::Auto => "AUTO",
74 ApprovalMode::Bypass => "BYPASS",
75 ApprovalMode::Suggest => "SUGGEST",
76 ApprovalMode::Never => "NEVER",
77 }
78 }
79
80 pub fn from_config_value(value: &str) -> Option<Self> {
81 match value.trim().to_ascii_lowercase().as_str() {
82 "auto" | "auto-review" | "auto_review" => Some(ApprovalMode::Auto),
83 "bypass" | "yolo" | "dontask" | "dont_ask" | "bypass-permissions"
84 | "bypasspermissions" | "full-access" | "full_access" | "full" => {
85 Some(ApprovalMode::Bypass)
86 }
87 "suggest" | "suggested" | "on-request" | "untrusted" | "ask" => {
88 Some(ApprovalMode::Suggest)
89 }
90 "never" | "deny" | "denied" => Some(ApprovalMode::Never),
91 _ => None,
92 }
93 }
94
95 #[must_use]
96 pub fn cycle_permission_next(self) -> Self {
97 let Some(index) = Self::PERMISSION_CYCLE.iter().position(|mode| *mode == self) else {
98 return Self::Suggest;
99 };
100 Self::PERMISSION_CYCLE[(index + 1) % Self::PERMISSION_CYCLE.len()]
101 }
102
103 #[must_use]
104 pub fn permission_chip_label(self) -> &'static str {
105 match self {
106 Self::Suggest => "Ask",
107 Self::Auto => "Auto-Review",
108 Self::Bypass => "Full Access",
109 Self::Never => "Never",
110 }
111 }
112 }
113
114 /// User's decision for a pending approval
115 #[derive(Debug, Clone, PartialEq, Eq)]
116 pub enum ReviewDecision {
117 /// Execute this tool once
118 Approved,
119 /// Approve and don't ask again for this tool type this session
120 ApprovedForSession,
121 /// Reject the tool execution
122 Denied,
123 /// Abort the entire turn
124 Abort,
125 }
126
127 /// Request for user approval of a tool execution
128 #[derive(Debug, Clone)]
129 pub struct ApprovalRequest {
130 /// Unique ID for this tool use
131 pub id: String,
132 /// Tool being executed
133 pub tool_name: String,
134 /// Human-readable tool description from the engine
135 pub description: String,
136 /// Tool category
137 pub category: ToolCategory,
138 /// Stakes-based routing for the compact approval card
139 pub risk: RiskLevel,
140 /// Derived impact summary for the approval prompt
141 pub impacts: Vec<String>,
142 /// Tool parameters (for display)
143 pub params: Value,
144 /// Exact-argument fingerprint, used to scope *denials* (#1617).
145 pub approval_key: String,
146 /// Lossy / arity-aware fingerprint, used to scope *approvals* so an
147 /// "approve for session" covers later flag variants (v0.8.37).
148 pub approval_grouping_key: String,
149 /// The model's explanation of intent before invoking write tools (#2381).
150 /// Displayed in the approval view so users understand *why* the change
151 /// is being made before reviewing *what* will change.
152 pub intent_summary: Option<String>,
153 /// Ask-only persistent rules that can be saved with the approval.
154 pub persistent_ask_rules: Vec<ToolAskRule>,
155 /// Exact repo-scoped allow rules available for safe approval requests.
156 pub persistent_allow_rules: Vec<ToolAskRule>,
157 }
158
159 /// Key approval details rendered prominently in the approval card.
160 #[derive(Debug, Clone, PartialEq, Eq)]
161 pub struct ApprovalDetail {
162 pub label: String,
163 pub value: String,
164 /// Preformatted shell lines for commands that benefit from safe wrapping
165 /// or a compact write-file preview. `value` remains the original command.
166 pub shell_lines: Option<Vec<String>>,
167 }
168
169 /// Human-readable preview of rules an approval action would append.
170 ///
171 /// This is intentionally derived from the already validated persistent-rule
172 /// candidates; the approval UI must not re-parse tool inputs such as patches.
173 #[derive(Debug, Clone, PartialEq, Eq)]
174 pub struct PermissionRuleSavePreview {
175 pub action: PermissionAction,
176 pub rule_count: usize,
177 pub entries: Vec<String>,
178 pub omitted: usize,
179 }
180
181 impl PermissionRuleSavePreview {
182 #[must_use]
183 pub fn summary(&self) -> String {
184 let action = match self.action {
185 PermissionAction::Allow => "allow",
186 PermissionAction::Ask => "ask",
187 PermissionAction::Deny => "deny",
188 };
189 let noun = if self.rule_count == 1 {
190 "rule"
191 } else {
192 "rules"
193 };
194 format!("{} {action} {noun}", self.rule_count)
195 }
196 }
197
198 const ASK_RULE_SAVE_PREVIEW_MAX_ENTRIES: usize = 4;
199
200 impl ApprovalRequest {
201 /// Mechanical repo-law asks are a distinct authority boundary, not an
202 /// ordinary risk prompt. The engine stamps this stable prefix when a
203 /// `.codewhale/constitution.json` ask rule forces review.
204 #[must_use]
205 pub fn is_repo_law_prompt(&self) -> bool {
206 description_is_repo_law_prompt(&self.description)
207 }
208
209 /// Presentation stakes for this request (see [`ApprovalStakes`]).
210 #[must_use]
211 pub fn stakes(&self) -> ApprovalStakes {
212 classify_stakes(&self.tool_name, self.category, self.risk, &self.params)
213 }
214
215 #[cfg(test)]
216 pub fn new(
217 id: &str,
218 tool_name: &str,
219 description: &str,
220 params: &Value,
221 approval_key: &str,
222 ) -> Self {
223 Self::new_with_intent(
224 id,
225 tool_name,
226 description,
227 params,
228 approval_key,
229 None,
230 Path::new("/workspace"),
231 )
232 }
233
234 pub fn new_with_intent(
235 id: &str,
236 tool_name: &str,
237 description: &str,
238 params: &Value,
239 approval_key: &str,
240 intent_summary: Option<&str>,
241 workspace: &Path,
242 ) -> Self {
243 let semantic_tool_name = canonical_action_alias(tool_name, params);
244 let category = get_tool_category_for_call(tool_name, params);
245 let risk = classify_risk(tool_name, category, params);
246 let approval_grouping_key =
247 crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0;
248 let persistent_ask_rules =
249 build_persistent_ask_rules(semantic_tool_name, params, workspace);
250 let persistent_allow_rules = if classify_stakes(tool_name, category, risk, params)
251 == ApprovalStakes::Critical
252 || description_is_repo_law_prompt(description)
253 {
254 Vec::new()
255 } else {
256 build_persistent_allow_rules(
257 semantic_tool_name,
258 params,
259 workspace,
260 &persistent_ask_rules,
261 )
262 };
263
264 Self {
265 id: id.to_string(),
266 tool_name: tool_name.to_string(),
267 description: description.to_string(),
268 category,
269 risk,
270 impacts: build_impact_summary(semantic_tool_name, category, params),
271 params: params.clone(),
272 approval_key: approval_key.to_string(),
273 approval_grouping_key,
274 intent_summary: intent_summary.and_then(|summary| {
275 let summary = summary.trim();
276 if summary.is_empty() {
277 None
278 } else {
279 Some(summary.to_string())
280 }
281 }),
282 persistent_ask_rules,
283 persistent_allow_rules,
284 }
285 }
286
287 /// Format parameters for display (truncated)
288 pub fn params_display(&self) -> String {
289 let truncated = truncate_params_value(&self.params, 200);
290 serde_json::to_string(&truncated).unwrap_or_else(|_| truncated.to_string())
291 }
292
293 pub fn description_for_locale(&self, locale: Locale) -> String {
294 match locale {
295 Locale::ZhHans => localized_description_zh_hans(self.category),
296 _ if self.category == ToolCategory::Shell => {
297 "Review the Bash command before it runs.".to_string()
298 }
299 _ => self.description.clone(),
300 }
301 }
302
303 pub fn impacts_for_locale(&self, locale: Locale) -> Vec<String> {
304 let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
305 match locale {
306 Locale::ZhHans => {
307 build_impact_summary_zh_hans(semantic_tool_name, self.category, &self.params)
308 }
309 _ => self.impacts.clone(),
310 }
311 }
312
313 #[must_use]
314 pub fn can_save_ask_rule(&self) -> bool {
315 !self.persistent_ask_rules.is_empty()
316 }
317
318 #[must_use]
319 pub fn can_save_allow_rule(&self) -> bool {
320 !self.persistent_allow_rules.is_empty()
321 && self.stakes() != ApprovalStakes::Critical
322 && !self.is_repo_law_prompt()
323 }
324
325 #[must_use]
326 pub fn ask_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
327 build_permission_rule_save_preview(
328 &self.persistent_ask_rules,
329 ASK_RULE_SAVE_PREVIEW_MAX_ENTRIES,
330 )
331 }
332
333 #[must_use]
334 pub fn allow_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
335 self.can_save_allow_rule().then(|| {
336 build_permission_rule_save_preview(
337 &self.persistent_allow_rules,
338 ASK_RULE_SAVE_PREVIEW_MAX_ENTRIES,
339 )
340 .expect("eligible allow rules are non-empty")
341 })
342 }
343
344 #[must_use]
345 #[cfg(test)]
346 pub fn ask_rule_preview(&self) -> Option<String> {
347 if self.persistent_ask_rules.is_empty() {
348 return None;
349 }
350 let permissions = codewhale_config::PermissionsToml {
351 rules: self.persistent_ask_rules.clone(),
352 };
353 toml::to_string_pretty(&permissions).ok()
354 }
355
356 /// Extract the most important params for the approval card.
357 #[must_use]
358 pub fn prominent_detail_items(&self, locale: Locale) -> Vec<ApprovalDetail> {
359 let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
360 build_prominent_details(semantic_tool_name, self.category, &self.params)
361 .into_iter()
362 .map(|mut detail| {
363 let is_preview = detail.label == "Preview";
364 detail.label = localize_detail_label(&detail.label, locale).to_string();
365 if is_preview && let Some(lines) = detail.shell_lines.as_mut() {
366 for line in lines.iter_mut() {
367 *line = localize_preview_shell_line(semantic_tool_name, line, locale)
368 .to_string();
369 }
370 detail.value = lines.join("\n");
371 }
372 detail
373 })
374 .collect()
375 }
376 }
377
378 fn description_is_repo_law_prompt(description: &str) -> bool {
379 description.starts_with("Repo law holds this write:")
380 && description.contains(".codewhale/constitution.json")
381 }
382
383 #[must_use]
384 fn build_permission_rule_save_preview(
385 rules: &[ToolAskRule],
386 max_entries: usize,
387 ) -> Option<PermissionRuleSavePreview> {
388 if rules.is_empty() {
389 return None;
390 }
391
392 let entries = rules
393 .iter()
394 .take(max_entries)
395 .map(format_permission_rule_save_entry)
396 .collect();
397 Some(PermissionRuleSavePreview {
398 action: rules[0].action,
399 rule_count: rules.len(),
400 entries,
401 omitted: rules.len().saturating_sub(max_entries),
402 })
403 }
404
405 #[must_use]
406 fn format_permission_rule_save_entry(rule: &ToolAskRule) -> String {
407 let mut parts = vec![format!(
408 "tool={}",
409 sanitize_ask_rule_preview_value(&rule.tool)
410 )];
411 if let Some(command) = &rule.command {
412 parts.push(format!(
413 "command={}",
414 sanitize_ask_rule_preview_value(command)
415 ));
416 }
417 if let Some(path) = &rule.path {
418 parts.push(format!("path={}", sanitize_ask_rule_preview_value(path)));
419 }
420 if rule.command_exact {
421 parts.push("command_exact=true".to_string());
422 }
423 if let Some(workspace) = &rule.workspace {
424 parts.push(format!(
425 "workspace={}",
426 sanitize_ask_rule_preview_value(workspace)
427 ));
428 }
429 parts.join(" ")
430 }
431
432 #[must_use]
433 fn sanitize_ask_rule_preview_value(value: &str) -> String {
434 value
435 .replace('\\', "\\\\")
436 .replace('\r', "\\r")
437 .replace('\n', "\\n")
438 .replace('\t', "\\t")
439 }
440
441 #[must_use]
442 fn build_persistent_ask_rules(
443 tool_name: &str,
444 params: &Value,
445 workspace: &Path,
446 ) -> Vec<ToolAskRule> {
447 match tool_name {
448 "exec_shell" => build_exec_shell_ask_rules(params),
449 // File writes save an exact, workspace-relative path so a later
450 // edit/write of the same file is matched. read_file stays out: this
451 // boundary is about persisting *write* approvals only.
452 "write_file" | "edit_file" => build_file_write_ask_rules(tool_name, params, workspace),
453 "apply_patch" => build_apply_patch_ask_rules(params, workspace),
454 _ => Vec::new(),
455 }
456 }
457
458 #[must_use]
459 fn build_persistent_allow_rules(
460 tool_name: &str,
461 params: &Value,
462 workspace: &Path,
463 exact_rules: &[ToolAskRule],
464 ) -> Vec<ToolAskRule> {
465 if exact_rules.is_empty() {
466 return Vec::new();
467 }
468
469 if tool_name == "exec_shell" {
470 let Some(command) = params.get("command").and_then(Value::as_str) else {
471 return Vec::new();
472 };
473 if !matches!(
474 crate::command_safety::analyze_command(command).level,
475 crate::command_safety::SafetyLevel::Safe
476 | crate::command_safety::SafetyLevel::WorkspaceSafe
477 ) {
478 return Vec::new();
479 }
480 }
481
482 let workspace = workspace.to_string_lossy();
483 let Some(workspace) = codewhale_execpolicy::normalize_workspace_scope(workspace.as_ref())
484 else {
485 return Vec::new();
486 };
487
488 exact_rules
489 .iter()
490 .cloned()
491 .map(|rule| rule.into_exact_workspace_allow(workspace.clone()))
492 .collect()
493 }
494
495 #[must_use]
496 fn build_exec_shell_ask_rules(params: &Value) -> Vec<ToolAskRule> {
497 let Some(command) = params
498 .get("command")
499 .and_then(Value::as_str)
500 .map(str::trim)
501 .filter(|command| !command.is_empty())
502 else {
503 return Vec::new();
504 };
505 vec![ToolAskRule::exec_shell(command)]
506 }
507
508 #[must_use]
509 fn build_file_write_ask_rules(
510 tool_name: &str,
511 params: &Value,
512 workspace: &Path,
513 ) -> Vec<ToolAskRule> {
514 let Some(path) = params
515 .get("path")
516 .and_then(Value::as_str)
517 .map(str::trim)
518 .filter(|path| !path.is_empty())
519 else {
520 return Vec::new();
521 };
522 // Reuse the canonical matcher normalization so the saved rule equals what
523 // runtime matching compares against. `None` (and the degenerate
524 // workspace-root case) means the path is empty, traversing, drive-relative,
525 // or outside the workspace, so we save nothing and the `S` shortcut and
526 // preview stay disabled.
527 let workspace = workspace.to_string_lossy();
528 let Some(relative) =
529 codewhale_execpolicy::normalize_workspace_relative_path(path, workspace.as_ref())
530 .filter(|relative| !relative.is_empty())
531 else {
532 return Vec::new();
533 };
534 vec![ToolAskRule::file_path(tool_name, relative)]
535 }
536
537 #[must_use]
538 fn build_apply_patch_ask_rules(params: &Value, workspace: &Path) -> Vec<ToolAskRule> {
539 let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(params) else {
540 return Vec::new();
541 };
542 let workspace = workspace.to_string_lossy();
543 let mut rules = Vec::new();
544
545 for path in preflight.touched_files {
546 let Some(relative) =
547 codewhale_execpolicy::normalize_workspace_relative_path(&path, workspace.as_ref())
548 .filter(|relative| !relative.is_empty())
549 else {
550 return Vec::new();
551 };
552 let rule = ToolAskRule::file_path("apply_patch", relative);
553 if !rules.contains(&rule) {
554 rules.push(rule);
555 }
556 }
557
558 rules
559 }
560
561 fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option<String> {
562 let Value::Object(map) = params else {
563 return None;
564 };
565
566 for key in keys {
567 let Some(value) = map.get(*key) else {
568 continue;
569 };
570 match value {
571 Value::String(text) => return Some(truncate_string_value(text, max_len)),
572 Value::Number(number) => return Some(number.to_string()),
573 Value::Bool(flag) => return Some(flag.to_string()),
574 Value::Array(items) if !items.is_empty() => {
575 let preview = items
576 .iter()
577 .take(3)
578 .map(|item| match item {
579 Value::String(text) => truncate_string_value(text, max_len / 2),
580 other => truncate_string_value(&other.to_string(), max_len / 2),
581 })
582 .collect::<Vec<_>>()
583 .join(", ");
584 return Some(truncate_string_value(&preview, max_len));
585 }
586 other => return Some(truncate_string_value(&other.to_string(), max_len)),
587 }
588 }
589
590 None
591 }
592
593 fn mcp_target_hint(tool_name: &str) -> Option<String> {
594 let remainder = tool_name.strip_prefix("mcp_")?;
595 if remainder.is_empty() {
596 None
597 } else {
598 Some(remainder.to_string())
599 }
600 }
601
602 fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) -> Vec<String> {
603 match category {
604 ToolCategory::Safe => {
605 let mut impacts = vec!["Read-only operation.".to_string()];
606 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
607 impacts.push(format!("Reads: {path}"));
608 }
609 impacts
610 }
611 ToolCategory::FileWrite => {
612 let mut impacts =
613 vec!["Writes files in the workspace or an approved write scope.".to_string()];
614 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
615 impacts.push(format!("Writes: {path}"));
616 }
617 impacts
618 }
619 ToolCategory::Shell => {
620 vec!["Executes a Bash command in your workspace.".to_string()]
621 }
622 ToolCategory::Network => {
623 let mut impacts = vec!["May reach network services or remote content.".to_string()];
624 if let Some(target) =
625 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
626 {
627 impacts.push(format!("Target: {target}"));
628 }
629 impacts
630 }
631 ToolCategory::McpRead => {
632 let mut impacts =
633 vec!["Reads from an MCP server without an obvious local write.".to_string()];
634 if let Some(target) = mcp_target_hint(tool_name) {
635 impacts.push(format!("MCP target: {target}"));
636 }
637 impacts
638 }
639 ToolCategory::McpAction => {
640 let mut impacts =
641 vec!["Calls an MCP server action that may have side effects.".to_string()];
642 if let Some(target) = mcp_target_hint(tool_name) {
643 impacts.push(format!("MCP target: {target}"));
644 }
645 impacts
646 }
647 ToolCategory::Agent if tool_name == "workflow" => {
648 // #4126: elevated Workflow plan card — goal, children, capability flags, budget.
649 crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params)
650 .approval_impacts()
651 }
652 ToolCategory::Agent => {
653 let mut impacts = vec![
654 "Starts or inspects a child agent task; the child's own tool gates still apply."
655 .to_string(),
656 ];
657 if let Some(kind) = param_preview(params, &["type"], 40) {
658 impacts.push(format!("Child type: {kind}"));
659 }
660 impacts
661 }
662 ToolCategory::Unknown => {
663 let mut impacts = vec![
664 "Tool is not classified. Review params carefully before approving.".to_string(),
665 ];
666 if let Some(target) = param_preview(
667 params,
668 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
669 96,
670 ) {
671 impacts.push(format!("Primary input: {target}"));
672 }
673 impacts
674 }
675 }
676 }
677
678 fn localized_description_zh_hans(category: ToolCategory) -> String {
679 let locale = Locale::ZhHans;
680 match category {
681 ToolCategory::Safe => tr(locale, MessageId::ApprovalDescSafe).to_string(),
682 ToolCategory::FileWrite => tr(locale, MessageId::ApprovalDescFileWrite).to_string(),
683 ToolCategory::Shell => tr(locale, MessageId::ApprovalDescShell).to_string(),
684 ToolCategory::Network => tr(locale, MessageId::ApprovalDescNetwork).to_string(),
685 ToolCategory::McpRead => tr(locale, MessageId::ApprovalDescMcpRead).to_string(),
686 ToolCategory::McpAction => tr(locale, MessageId::ApprovalDescMcpAction).to_string(),
687 ToolCategory::Agent => tr(locale, MessageId::ApprovalDescAgent).to_string(),
688 ToolCategory::Unknown => tr(locale, MessageId::ApprovalDescUnknown).to_string(),
689 }
690 }
691
692 fn build_impact_summary_zh_hans(
693 tool_name: &str,
694 category: ToolCategory,
695 params: &Value,
696 ) -> Vec<String> {
697 let locale = Locale::ZhHans;
698 match category {
699 ToolCategory::Safe => {
700 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactSafe).to_string()];
701 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
702 impacts.push(format!("读取:{path}"));
703 }
704 impacts
705 }
706 ToolCategory::FileWrite => {
707 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactFileWrite).to_string()];
708 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
709 impacts.push(format!("写入:{path}"));
710 }
711 impacts
712 }
713 ToolCategory::Shell => {
714 vec![tr(locale, MessageId::ApprovalImpactShell).to_string()]
715 }
716 ToolCategory::Network => {
717 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactNetwork).to_string()];
718 if let Some(target) =
719 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
720 {
721 impacts.push(format!("目标:{target}"));
722 }
723 impacts
724 }
725 ToolCategory::McpRead => {
726 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpRead).to_string()];
727 if let Some(target) = mcp_target_hint(tool_name) {
728 impacts.push(format!("MCP 目标:{target}"));
729 }
730 impacts
731 }
732 ToolCategory::McpAction => {
733 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpAction).to_string()];
734 if let Some(target) = mcp_target_hint(tool_name) {
735 impacts.push(format!("MCP 目标:{target}"));
736 }
737 impacts
738 }
739 ToolCategory::Agent => {
740 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactAgent).to_string()];
741 if let Some(kind) = param_preview(params, &["type"], 40) {
742 impacts.push(format!("子代理类型:{kind}"));
743 }
744 impacts
745 }
746 ToolCategory::Unknown => {
747 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactUnknown).to_string()];
748 if let Some(target) = param_preview(
749 params,
750 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
751 96,
752 ) {
753 impacts.push(format!("主要输入:{target}"));
754 }
755 impacts
756 }
757 }
758 }
759
760 fn build_prominent_details(
761 tool_name: &str,
762 category: ToolCategory,
763 params: &Value,
764 ) -> Vec<ApprovalDetail> {
765 let mut details = Vec::new();
766 match category {
767 ToolCategory::Shell => {
768 if let Some(command) = param_text(params, &["command", "cmd"]) {
769 details.push(ApprovalDetail {
770 label: "Command".to_string(),
771 shell_lines: Some(format_shell_command_for_approval(&command)),
772 value: command,
773 });
774 }
775 if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) {
776 details.push(ApprovalDetail {
777 label: "Dir".to_string(),
778 value: workdir,
779 shell_lines: None,
780 });
781 }
782 }
783 ToolCategory::FileWrite => {
784 if let Some(path) = param_preview(params, &["path", "target", "destination"], 200) {
785 details.push(ApprovalDetail {
786 label: "File".to_string(),
787 value: path,
788 shell_lines: None,
789 });
790 }
791 if let Some(preview_lines) = file_write_preview_lines(tool_name, params) {
792 details.push(ApprovalDetail {
793 label: "Preview".to_string(),
794 value: preview_lines.join("\n"),
795 shell_lines: Some(preview_lines),
796 });
797 }
798 }
799 ToolCategory::Safe => {
800 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 200) {
801 details.push(ApprovalDetail {
802 label: "Path".to_string(),
803 value: path,
804 shell_lines: None,
805 });
806 }
807 }
808 ToolCategory::Network => {
809 if let Some(target) =
810 param_preview(params, &["url", "q", "query", "location", "repo"], 200)
811 {
812 details.push(ApprovalDetail {
813 label: "Target".to_string(),
814 value: target,
815 shell_lines: None,
816 });
817 }
818 }
819 ToolCategory::Agent if tool_name == "workflow" => {
820 // #4126: elevated Workflow plan card fields.
821 let summary =
822 crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params);
823 for (label, value) in summary.card_fields() {
824 details.push(ApprovalDetail {
825 label: label.to_string(),
826 value,
827 shell_lines: None,
828 });
829 }
830 }
831 ToolCategory::Agent => {
832 if let Some(action) = param_preview(params, &["action"], 40) {
833 details.push(ApprovalDetail {
834 label: "Action".to_string(),
835 value: action,
836 shell_lines: None,
837 });
838 }
839 if let Some(kind) = param_preview(params, &["type"], 40) {
840 details.push(ApprovalDetail {
841 label: "Type".to_string(),
842 value: kind,
843 shell_lines: None,
844 });
845 }
846 if let Some(prompt) = param_preview(params, &["prompt", "task", "message"], 200) {
847 details.push(ApprovalDetail {
848 label: "Prompt".to_string(),
849 value: prompt,
850 shell_lines: None,
851 });
852 }
853 }
854 ToolCategory::McpRead | ToolCategory::McpAction | ToolCategory::Unknown => {
855 if let Some(input) = param_preview(
856 params,
857 &["command", "cmd", "path", "url", "q", "query", "ref_id"],
858 200,
859 ) {
860 details.push(ApprovalDetail {
861 label: "Input".to_string(),
862 value: input,
863 shell_lines: None,
864 });
865 }
866 }
867 }
868 details
869 }
870
871 fn file_write_preview_lines(tool_name: &str, params: &Value) -> Option<Vec<String>> {
872 match tool_name {
873 "write_file" => {
874 let content = param_text(params, &["content"])?;
875 Some(prefixed_preview_lines(
876 "proposed content",
877 "+ ",
878 &content,
879 5,
880 ))
881 }
882 "edit_file" => {
883 // Keep the per-frame card preview bounded. The details pager builds the
884 // complete version lazily when the reviewer asks for it.
885 edit_file_preview_lines(params, 3)
886 }
887 "apply_patch" => match normalize_apply_patch_input(params) {
888 Ok(NormalizedApplyPatchInput::Patch(patch)) => apply_patch_preview_lines(patch),
889 Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => {
890 changes_preview_lines(entries)
891 }
892 Err(_) => None,
893 },
894 _ => None,
895 }
896 .filter(|lines| !lines.is_empty())
897 }
898
899 fn edit_file_preview_lines(params: &Value, max_lines: usize) -> Option<Vec<String>> {
900 let search = param_text(params, &["search"])?;
901 let replace = param_text(params, &["replace"])?;
902 let mut lines = Vec::new();
903 lines.extend(prefixed_preview_lines(
904 "replace this",
905 "- ",
906 &search,
907 max_lines,
908 ));
909 lines.extend(prefixed_preview_lines(
910 "with this",
911 "+ ",
912 &replace,
913 max_lines,
914 ));
915 Some(lines)
916 }
917
918 fn exact_edit_file_preview_lines(params: &Value, locale: Locale) -> Option<Vec<String>> {
919 let search = param_text(params, &["search"])?;
920 let replace = param_text(params, &["replace"])?;
921 let mut lines = vec![tr(locale, MessageId::ApprovalLabelReplaceThis).into_owned()];
922 lines.extend(exact_preview_body_lines("- ", &search));
923 lines.push(tr(locale, MessageId::ApprovalLabelWithThis).into_owned());
924 lines.extend(exact_preview_body_lines("+ ", &replace));
925 Some(lines)
926 }
927
928 fn exact_preview_body_lines(prefix: &str, content: &str) -> Vec<String> {
929 if content.is_empty() {
930 return vec![format!("{prefix}\"\"")];
931 }
932
933 content
934 .split_inclusive('\n')
935 .map(|chunk| {
936 let (body, ending) = if let Some(body) = chunk.strip_suffix("\r\n") {
937 (body, "\\r\\n")
938 } else if let Some(body) = chunk.strip_suffix('\n') {
939 (body, "\\n")
940 } else {
941 (chunk, "")
942 };
943 exact_preview_body_line(prefix, body, ending)
944 })
945 .collect()
946 }
947
948 fn exact_preview_body_line(prefix: &str, body: &str, ending: &str) -> String {
949 let mut line = String::with_capacity(prefix.len() + body.len() + ending.len() + 2);
950 line.push_str(prefix);
951 line.push('"');
952 for ch in body.chars() {
953 match ch {
954 '\\' => line.push_str("\\\\"),
955 '"' => line.push_str("\\\""),
956 ' ' => line.push_str("\\x20"),
957 '\t' => line.push_str("\\t"),
958 '\r' => line.push_str("\\r"),
959 ch if ch.is_whitespace() || ch.is_control() => line.extend(ch.escape_unicode()),
960 ch => line.push(ch),
961 }
962 }
963 line.push_str(ending);
964 line.push('"');
965 line
966 }
967
968 fn prefixed_preview_lines(
969 header: &str,
970 prefix: &str,
971 content: &str,
972 max_lines: usize,
973 ) -> Vec<String> {
974 let mut lines = vec![header.to_string()];
975 if content.is_empty() {
976 lines.push(format!("{prefix}<empty>"));
977 return lines;
978 }
979
980 let total = content.lines().count();
981 for line in content.lines().take(max_lines) {
982 lines.push(format!("{prefix}{line}"));
983 }
984 if total > max_lines {
985 lines.push(format!("... (+{} more lines)", total - max_lines));
986 }
987 lines
988 }
989
990 fn push_preview_line(lines: &mut Vec<String>, line: impl Into<String>, limit: usize) -> bool {
991 if lines.len() >= limit {
992 return false;
993 }
994 lines.push(line.into());
995 true
996 }
997
998 fn append_preview_truncation(lines: &mut Vec<String>, line: String, limit: usize) {
999 if push_preview_line(lines, line.clone(), limit) {
1000 return;
1001 }
1002 if let Some(last) = lines.last_mut() {
1003 *last = line;
1004 }
1005 }
1006
1007 fn apply_patch_preview_lines(patch: &str) -> Option<Vec<String>> {
1008 const PREVIEW_LIMIT: usize = 7;
1009
1010 let mut lines = Vec::new();
1011 let mut omitted = 0usize;
1012 for line in patch.lines().filter(|line| !line.trim().is_empty()) {
1013 let is_diff_header = line.starts_with("diff --git ")
1014 || line.starts_with("--- ")
1015 || line.starts_with("+++ ")
1016 || line.starts_with("@@");
1017 let is_change_line = (line.starts_with('+') && !line.starts_with("+++"))
1018 || (line.starts_with('-') && !line.starts_with("---"));
1019 if is_diff_header || is_change_line {
1020 if !push_preview_line(&mut lines, line, PREVIEW_LIMIT) {
1021 omitted += 1;
1022 }
1023 } else {
1024 omitted += 1;
1025 }
1026 }
1027
1028 if lines.is_empty() {
1029 omitted = 0;
1030 for line in patch.lines().filter(|line| !line.trim().is_empty()) {
1031 if !push_preview_line(&mut lines, line, PREVIEW_LIMIT) {
1032 omitted += 1;
1033 }
1034 }
1035 }
1036
1037 if omitted > 0 {
1038 if lines.len() >= PREVIEW_LIMIT {
1039 omitted += 1;
1040 }
1041 append_preview_truncation(
1042 &mut lines,
1043 format!("... (+{omitted} more patch lines)"),
1044 PREVIEW_LIMIT,
1045 );
1046 }
1047 if lines.is_empty() { None } else { Some(lines) }
1048 }
1049
1050 fn changes_preview_lines(changes: &[Value]) -> Option<Vec<String>> {
1051 const PREVIEW_LIMIT: usize = 7;
1052
1053 let mut lines = Vec::new();
1054 let mut rendered_changes = 0usize;
1055 for (idx, change) in changes.iter().enumerate() {
1056 let path = change
1057 .get("path")
1058 .and_then(Value::as_str)
1059 .unwrap_or("<file>");
1060 let content = change.get("content").and_then(Value::as_str).unwrap_or("");
1061 if idx > 0 && !push_preview_line(&mut lines, String::new(), PREVIEW_LIMIT) {
1062 break;
1063 }
1064 if !push_preview_line(&mut lines, format!("file: {path}"), PREVIEW_LIMIT) {
1065 break;
1066 }
1067 rendered_changes += 1;
1068 for line in prefixed_preview_lines("replacement content", "+ ", content, PREVIEW_LIMIT)
1069 .into_iter()
1070 .skip(1)
1071 {
1072 if !push_preview_line(&mut lines, line, PREVIEW_LIMIT) {
1073 break;
1074 }
1075 }
1076 if lines.len() >= PREVIEW_LIMIT {
1077 break;
1078 }
1079 }
1080 let skipped_changes = changes.len().saturating_sub(rendered_changes);
1081 if skipped_changes > 0 {
1082 append_preview_truncation(
1083 &mut lines,
1084 format!("... (+{skipped_changes} more files)"),
1085 PREVIEW_LIMIT,
1086 );
1087 }
1088 if lines.is_empty() { None } else { Some(lines) }
1089 }
1090
1091 fn param_text(params: &Value, keys: &[&str]) -> Option<String> {
1092 let Value::Object(map) = params else {
1093 return None;
1094 };
1095
1096 for key in keys {
1097 let Some(value) = map.get(*key) else {
1098 continue;
1099 };
1100 match value {
1101 Value::String(text) => return Some(text.clone()),
1102 Value::Number(number) => return Some(number.to_string()),
1103 Value::Bool(flag) => return Some(flag.to_string()),
1104 other => return Some(other.to_string()),
1105 }
1106 }
1107
1108 None
1109 }
1110
1111 fn localize_detail_label(label: &str, locale: Locale) -> Cow<'static, str> {
1112 match locale {
1113 Locale::ZhHans => match label {
1114 "Command" => tr(locale, MessageId::ApprovalLabelCommand),
1115 "Dir" => tr(locale, MessageId::ApprovalLabelDir),
1116 "File" => tr(locale, MessageId::ApprovalLabelFile),
1117 "Preview" => tr(locale, MessageId::ApprovalLabelPreview),
1118 "proposed content" => tr(locale, MessageId::ApprovalLabelProposedContent),
1119 "replace this" => tr(locale, MessageId::ApprovalLabelReplaceThis),
1120 "with this" => tr(locale, MessageId::ApprovalLabelWithThis),
1121 "replacement content" => tr(locale, MessageId::ApprovalLabelReplacementContent),
1122 "Path" => tr(locale, MessageId::ApprovalLabelPath),
1123 "Target" => tr(locale, MessageId::ApprovalLabelTarget),
1124 "Input" => tr(locale, MessageId::ApprovalLabelInput),
1125 "Action" => tr(locale, MessageId::ApprovalLabelAction),
1126 "Type" => tr(locale, MessageId::ApprovalLabelType),
1127 "Prompt" => tr(locale, MessageId::ApprovalLabelPrompt),
1128 "Goal" => "目标".into(),
1129 "Children" => "子任务".into(),
1130 "Writes" => "写入".into(),
1131 "Shell" => "Shell".into(),
1132 "Network" => "网络".into(),
1133 "Budget" => "预算".into(),
1134 _ => label.to_string().into(),
1135 },
1136 _ => label.to_string().into(),
1137 }
1138 }
1139
1140 fn localize_preview_shell_line(tool_name: &str, line: &str, locale: Locale) -> Cow<'static, str> {
1141 match tool_name {
1142 "write_file" if line == "proposed content" => localize_detail_label(line, locale),
1143 "edit_file" if matches!(line, "replace this" | "with this") => {
1144 localize_detail_label(line, locale)
1145 }
1146 _ => line.to_string().into(),
1147 }
1148 }
1149
1150 pub(crate) fn format_shell_command_for_approval(command: &str) -> Vec<String> {
1151 if let Some(preview) = parse_printf_write_file_command(command) {
1152 return format_printf_write_file_preview(preview);
1153 }
1154
1155 let mut out = Vec::new();
1156 for raw_line in command.lines() {
1157 split_shell_display_line(raw_line, &mut out);
1158 }
1159 if out.is_empty() && !command.trim().is_empty() {
1160 out.push(command.trim().to_string());
1161 }
1162 out
1163 }
1164
1165 fn split_shell_display_line(line: &str, out: &mut Vec<String>) {
1166 let mut quote: Option<char> = None;
1167 let mut escaped = false;
1168 let mut current = String::new();
1169 let mut chars = line.chars().peekable();
1170
1171 while let Some(ch) = chars.next() {
1172 if escaped {
1173 current.push(ch);
1174 escaped = false;
1175 continue;
1176 }
1177
1178 if ch == '\\' {
1179 current.push(ch);
1180 escaped = true;
1181 continue;
1182 }
1183
1184 if matches!(ch, '"' | '\'') {
1185 if quote == Some(ch) {
1186 quote = None;
1187 } else if quote.is_none() {
1188 quote = Some(ch);
1189 }
1190 current.push(ch);
1191 continue;
1192 }
1193
1194 if quote.is_none() {
1195 match ch {
1196 '&' if chars.peek() == Some(&'&') => {
1197 chars.next();
1198 push_shell_clause(out, &mut current, Some("&&"));
1199 continue;
1200 }
1201 '|' if chars.peek() == Some(&'|') => {
1202 chars.next();
1203 push_shell_clause(out, &mut current, Some("||"));
1204 continue;
1205 }
1206 '|' => {
1207 push_shell_clause(out, &mut current, Some("|"));
1208 continue;
1209 }
1210 ';' => {
1211 push_shell_clause(out, &mut current, Some(";"));
1212 continue;
1213 }
1214 _ => {}
1215 }
1216 }
1217
1218 current.push(ch);
1219 }
1220
1221 push_shell_clause(out, &mut current, None);
1222 }
1223
1224 fn push_shell_clause(out: &mut Vec<String>, current: &mut String, operator: Option<&str>) {
1225 let trimmed = current.trim();
1226 if trimmed.is_empty() {
1227 if let Some(operator) = operator {
1228 out.push(operator.to_string());
1229 }
1230 } else if let Some(operator) = operator {
1231 out.push(format!("{trimmed} {operator}"));
1232 } else {
1233 out.push(trimmed.to_string());
1234 }
1235 current.clear();
1236 }
1237
1238 #[derive(Debug, Clone, PartialEq, Eq)]
1239 struct PrintfWriteFilePreview {
1240 target: String,
1241 lines: Vec<String>,
1242 }
1243
1244 fn parse_printf_write_file_command(command: &str) -> Option<PrintfWriteFilePreview> {
1245 let (before_redirect, after_redirect) = split_unquoted_redirect(command)?;
1246 let before_redirect = before_redirect.trim();
1247 if !before_redirect.starts_with("printf") {
1248 return None;
1249 }
1250
1251 let tokens = shlex::split(before_redirect)?;
1252 if tokens.first()?.as_str() != "printf" {
1253 return None;
1254 }
1255 let target_parts = shlex::split(after_redirect.trim())?;
1256 if target_parts.len() != 1 {
1257 return None;
1258 }
1259 let target = target_parts
1260 .into_iter()
1261 .next()?
1262 .trim_matches(|ch| ch == '"' || ch == '\'')
1263 .to_string();
1264 if target.is_empty() {
1265 return None;
1266 }
1267
1268 let args = &tokens[1..];
1269 if args.is_empty() {
1270 return None;
1271 }
1272 let values = if args.len() >= 2 && args[0].contains('%') {
1273 &args[1..]
1274 } else {
1275 args
1276 };
1277 let mut lines = Vec::new();
1278 for value in values {
1279 let normalized = value.replace("\\n", "\n");
1280 for line in normalized.lines() {
1281 lines.push(line.to_string());
1282 }
1283 }
1284 if lines.is_empty() {
1285 lines.push(String::new());
1286 }
1287
1288 Some(PrintfWriteFilePreview { target, lines })
1289 }
1290
1291 fn format_printf_write_file_preview(preview: PrintfWriteFilePreview) -> Vec<String> {
1292 const MAX_PREVIEW_LINES: usize = 12;
1293 let mut out = vec![format!("printf > {}", preview.target)];
1294 let total = preview.lines.len();
1295 for line in preview.lines.into_iter().take(MAX_PREVIEW_LINES) {
1296 out.push(format!(" {line}"));
1297 }
1298 if total > MAX_PREVIEW_LINES {
1299 out.push(format!(" ... (+{} more lines)", total - MAX_PREVIEW_LINES));
1300 }
1301 out
1302 }
1303
1304 fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
1305 let mut quote: Option<char> = None;
1306 let mut escaped = false;
1307 for (idx, ch) in command.char_indices() {
1308 if escaped {
1309 escaped = false;
1310 continue;
1311 }
1312 if ch == '\\' {
1313 escaped = true;
1314 continue;
1315 }
1316 if matches!(ch, '"' | '\'') {
1317 if quote == Some(ch) {
1318 quote = None;
1319 } else if quote.is_none() {
1320 quote = Some(ch);
1321 }
1322 continue;
1323 }
1324 if quote.is_none() && ch == '>' {
1325 return Some((&command[..idx], &command[idx + ch.len_utf8()..]));
1326 }
1327 }
1328 None
1329 }
1330
1331 /// Indices into the option list shared by both variants.
1332 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1333 pub enum ApprovalOption {
1334 ApproveOnce,
1335 ApproveAlways,
1336 AllowExactRepo,
1337 Deny,
1338 Abort,
1339 }
1340
1341 impl ApprovalOption {
1342 const ORDER: [ApprovalOption; 4] = [
1343 ApprovalOption::ApproveOnce,
1344 ApprovalOption::ApproveAlways,
1345 ApprovalOption::Deny,
1346 ApprovalOption::Abort,
1347 ];
1348 const ORDER_WITH_PERSISTENT_ALLOW: [ApprovalOption; 5] = [
1349 ApprovalOption::ApproveOnce,
1350 ApprovalOption::ApproveAlways,
1351 ApprovalOption::AllowExactRepo,
1352 ApprovalOption::Deny,
1353 ApprovalOption::Abort,
1354 ];
1355
1356 /// Workflow elevated-plan card (#4126): Approve / Edit plan / Cancel.
1357 const WORKFLOW_ORDER: [ApprovalOption; 3] = [
1358 ApprovalOption::ApproveOnce,
1359 ApprovalOption::Deny,
1360 ApprovalOption::Abort,
1361 ];
1362
1363 fn order_for(request: &ApprovalRequest) -> &'static [ApprovalOption] {
1364 if request.tool_name == "workflow" {
1365 &Self::WORKFLOW_ORDER
1366 } else if request.can_save_allow_rule() {
1367 &Self::ORDER_WITH_PERSISTENT_ALLOW
1368 } else {
1369 &Self::ORDER
1370 }
1371 }
1372
1373 fn from_index_for(request: &ApprovalRequest, idx: usize) -> ApprovalOption {
1374 Self::order_for(request)
1375 .get(idx)
1376 .copied()
1377 .unwrap_or(Self::Abort)
1378 }
1379
1380 fn index_for(self, request: &ApprovalRequest) -> usize {
1381 Self::order_for(request)
1382 .iter()
1383 .position(|o| *o == self)
1384 .unwrap_or(Self::order_for(request).len().saturating_sub(1))
1385 }
1386
1387 fn decision(self) -> ReviewDecision {
1388 match self {
1389 ApprovalOption::ApproveOnce => ReviewDecision::Approved,
1390 ApprovalOption::ApproveAlways => ReviewDecision::ApprovedForSession,
1391 ApprovalOption::AllowExactRepo => ReviewDecision::Approved,
1392 // Workflow maps Deny → "Edit plan" (model revises plan).
1393 ApprovalOption::Deny => ReviewDecision::Denied,
1394 ApprovalOption::Abort => ReviewDecision::Abort,
1395 }
1396 }
1397 }
1398
1399 /// Approval overlay state managed by the modal view stack
1400 #[derive(Debug, Clone)]
1401 pub struct ApprovalView {
1402 request: ApprovalRequest,
1403 selected: usize,
1404 row_hitboxes: RefCell<Vec<Rect>>,
1405 locale: Locale,
1406 timeout: Option<Duration>,
1407 requested_at: Instant,
1408 /// Whether the approval card is collapsed to a single-line banner.
1409 pub(crate) collapsed: bool,
1410 }
1411
1412 impl ApprovalView {
1413 #[cfg(test)]
1414 pub fn new(request: ApprovalRequest) -> Self {
1415 Self::new_for_locale(request, Locale::En)
1416 }
1417
1418 pub fn new_for_locale(request: ApprovalRequest, locale: Locale) -> Self {
1419 // A fresh card must never turn a reflexive Enter into authorization.
1420 // Resolve the semantic deny option because its numeric index differs
1421 // for persistent-allow and workflow approval cards.
1422 let selected = ApprovalOption::Deny.index_for(&request);
1423 Self {
1424 request,
1425 selected,
1426 row_hitboxes: RefCell::new(Vec::new()),
1427 locale,
1428 timeout: None,
1429 requested_at: Instant::now(),
1430 collapsed: false,
1431 }
1432 }
1433
1434 fn select_prev(&mut self) {
1435 let len = ApprovalOption::order_for(&self.request).len();
1436 self.selected = crate::tui::list_nav::wrap_index(self.selected, len, -1);
1437 }
1438
1439 fn select_next(&mut self) {
1440 let len = ApprovalOption::order_for(&self.request).len();
1441 self.selected = crate::tui::list_nav::wrap_index(self.selected, len, 1);
1442 }
1443
1444 fn current_option(&self) -> ApprovalOption {
1445 ApprovalOption::from_index_for(&self.request, self.selected)
1446 }
1447
1448 /// Whether this approval is the elevated Workflow plan card (#4126).
1449 #[must_use]
1450 pub fn is_workflow_plan_approval(&self) -> bool {
1451 self.request.tool_name == "workflow"
1452 }
1453
1454 /// Test-only accessor for the selected option's decision.
1455 #[cfg(test)]
1456 fn current_decision(&self) -> ReviewDecision {
1457 self.current_option().decision()
1458 }
1459
1460 /// Selected option for the renderer (used by the widget tests too).
1461 pub fn selected(&self) -> usize {
1462 self.selected
1463 }
1464
1465 pub(crate) fn set_mouse_hitboxes(&self, hitboxes: Vec<Rect>) {
1466 *self.row_hitboxes.borrow_mut() = hitboxes;
1467 }
1468
1469 /// Risk level for the renderer's accent picking.
1470 #[cfg(test)]
1471 pub fn risk(&self) -> RiskLevel {
1472 self.request.risk
1473 }
1474
1475 pub(crate) fn locale(&self) -> Locale {
1476 self.locale
1477 }
1478
1479 /// Commit the given option and close the approval modal.
1480 fn commit_option(&mut self, option: ApprovalOption) -> ViewAction {
1481 self.selected = option.index_for(&self.request);
1482 if option == ApprovalOption::AllowExactRepo && self.request.can_save_allow_rule() {
1483 self.emit_decision_with_rules(
1484 option.decision(),
1485 false,
1486 self.request.persistent_allow_rules.clone(),
1487 )
1488 } else {
1489 self.emit_decision(option.decision(), false)
1490 }
1491 }
1492
1493 fn emit_decision(&self, decision: ReviewDecision, timed_out: bool) -> ViewAction {
1494 self.emit_decision_with_rules(decision, timed_out, Vec::new())
1495 }
1496
1497 fn emit_decision_with_rules(
1498 &self,
1499 decision: ReviewDecision,
1500 timed_out: bool,
1501 persistent_rules: Vec<ToolAskRule>,
1502 ) -> ViewAction {
1503 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1504 tool_id: self.request.id.clone(),
1505 tool_name: self.request.tool_name.clone(),
1506 decision,
1507 timed_out,
1508 approval_key: self.request.approval_key.clone(),
1509 approval_grouping_key: self.request.approval_grouping_key.clone(),
1510 persistent_rules,
1511 })
1512 }
1513
1514 fn emit_params_pager(&self) -> ViewAction {
1515 // The compact prompt keeps the about/impact dossier out of the
1516 // default band; the pager is where that context now lives.
1517 let locale = self.locale();
1518 let about_label = tr(locale, MessageId::ApprovalLabelAbout);
1519 let impact_label = tr(locale, MessageId::ApprovalLabelImpact);
1520 let mut content = String::new();
1521 content.push_str(&about_label);
1522 content.push_str(&self.request.description_for_locale(locale));
1523 content.push('\n');
1524 for impact in self.request.impacts_for_locale(locale) {
1525 content.push_str(&impact_label);
1526 content.push_str(&impact);
1527 content.push('\n');
1528 }
1529 content.push('\n');
1530 if canonical_action_alias(&self.request.tool_name, &self.request.params) == "edit_file"
1531 && let Some(preview_lines) = exact_edit_file_preview_lines(&self.request.params, locale)
1532 {
1533 content.push_str(&tr(locale, MessageId::ApprovalLabelPreview));
1534 content.push_str(":\n");
1535 for line in preview_lines {
1536 content.push_str(&line);
1537 content.push('\n');
1538 }
1539 content.push('\n');
1540 }
1541 content.push_str(
1542 &serde_json::to_string_pretty(&self.request.params)
1543 .unwrap_or_else(|_| self.request.params.to_string()),
1544 );
1545 ViewAction::Emit(ViewEvent::OpenTextPager {
1546 title: format!("Tool Params: {}", self.request.tool_name),
1547 content,
1548 })
1549 }
1550
1551 fn is_timed_out(&self) -> bool {
1552 match self.timeout {
1553 Some(timeout) => self.requested_at.elapsed() >= timeout,
1554 None => false,
1555 }
1556 }
1557 }
1558
1559 impl ModalView for ApprovalView {
1560 fn kind(&self) -> ModalKind {
1561 ModalKind::Approval
1562 }
1563
1564 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1565 self
1566 }
1567
1568 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
1569 match key.code {
1570 KeyCode::Tab => {
1571 self.collapsed = !self.collapsed;
1572 ViewAction::None
1573 }
1574 KeyCode::Up | KeyCode::Char('k') => {
1575 self.select_prev();
1576 ViewAction::None
1577 }
1578 KeyCode::Down | KeyCode::Char('j') => {
1579 self.select_next();
1580 ViewAction::None
1581 }
1582 KeyCode::Enter => self.commit_option(self.current_option()),
1583 // Direct shortcuts; '1' / '2' map to the first two options
1584 // so a numeric pad still works for approve flows.
1585 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1') => {
1586 self.commit_option(ApprovalOption::ApproveOnce)
1587 }
1588 KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Char('2')
1589 if !self.is_workflow_plan_approval() =>
1590 {
1591 self.commit_option(ApprovalOption::ApproveAlways)
1592 }
1593 KeyCode::Char('p') | KeyCode::Char('P') if self.request.can_save_allow_rule() => {
1594 self.commit_option(ApprovalOption::AllowExactRepo)
1595 }
1596 // Workflow plan card (#4126): [2/e] Edit plan, [3/n/d] Cancel.
1597 KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Char('2')
1598 if self.is_workflow_plan_approval() =>
1599 {
1600 self.commit_option(ApprovalOption::Deny)
1601 }
1602 KeyCode::Char('s') | KeyCode::Char('S') if self.request.can_save_ask_rule() => self
1603 .emit_decision_with_rules(
1604 ReviewDecision::Approved,
1605 false,
1606 self.request.persistent_ask_rules.clone(),
1607 ),
1608 KeyCode::Char('n')
1609 | KeyCode::Char('N')
1610 | KeyCode::Char('d')
1611 | KeyCode::Char('D')
1612 | KeyCode::Char('3') => {
1613 if self.is_workflow_plan_approval() {
1614 // Cancel (abort turn) rather than session-deny.
1615 self.commit_option(ApprovalOption::Abort)
1616 } else {
1617 self.commit_option(ApprovalOption::Deny)
1618 }
1619 }
1620 // Details is Alt+V / Option+V only; bare `v` is never a shortcut.
1621 _ if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) => {
1622 self.emit_params_pager()
1623 }
1624 KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false),
1625 _ => ViewAction::None,
1626 }
1627 }
1628
1629 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
1630 match mouse.kind {
1631 MouseEventKind::ScrollUp => {
1632 self.select_prev();
1633 ViewAction::None
1634 }
1635 MouseEventKind::ScrollDown => {
1636 self.select_next();
1637 ViewAction::None
1638 }
1639 MouseEventKind::Down(MouseButton::Left) => {
1640 let clicked = self.row_hitboxes.borrow().iter().position(|rect| {
1641 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
1642 });
1643 if let Some(index) = clicked {
1644 return self
1645 .commit_option(ApprovalOption::from_index_for(&self.request, index));
1646 }
1647 ViewAction::None
1648 }
1649 _ => ViewAction::None,
1650 }
1651 }
1652
1653 fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
1654 let approval_widget = ApprovalWidget::new(&self.request, self);
1655 approval_widget.render(area, buf);
1656 }
1657
1658 fn occupied_region(&self, area: ratatui::layout::Rect) -> ratatui::layout::Rect {
1659 // The approval is an inline, bottom-anchored prompt: it only occupies
1660 // a band at the bottom of the frame so the backdrop dims that band and
1661 // the transcript above stays visible. Must match what `render` paints.
1662 ApprovalWidget::new(&self.request, self).inline_region(area)
1663 }
1664
1665 fn tick(&mut self) -> ViewAction {
1666 if self.is_timed_out() {
1667 return self.emit_decision(ReviewDecision::Denied, true);
1668 }
1669 ViewAction::None
1670 }
1671 }
1672
1673 fn truncate_params_value(value: &Value, max_len: usize) -> Value {
1674 match value {
1675 Value::Object(map) => {
1676 let truncated = map
1677 .iter()
1678 .map(|(key, val)| (key.clone(), truncate_params_value(val, max_len)))
1679 .collect();
1680 Value::Object(truncated)
1681 }
1682 Value::Array(items) => {
1683 let truncated_items = items
1684 .iter()
1685 .map(|val| truncate_params_value(val, max_len))
1686 .collect();
1687 Value::Array(truncated_items)
1688 }
1689 Value::String(text) => Value::String(truncate_string_value(text, max_len)),
1690 other => {
1691 let rendered = other.to_string();
1692 if rendered.chars().count() > max_len {
1693 Value::String(truncate_string_value(&rendered, max_len))
1694 } else {
1695 other.clone()
1696 }
1697 }
1698 }
1699 }
1700
1701 fn truncate_string_value(value: &str, max_len: usize) -> String {
1702 if value.chars().count() <= max_len {
1703 return value.to_string();
1704 }
1705 let truncated: String = value.chars().take(max_len).collect();
1706 format!("{truncated}...")
1707 }
1708
1709 // ============================================================================
1710 // Sandbox Elevation Flow
1711 // ============================================================================
1712
1713 /// Options for elevating sandbox permissions after a denial.
1714 #[derive(Debug, Clone, PartialEq, Eq)]
1715 pub enum ElevationOption {
1716 /// Add network access to the sandbox policy.
1717 WithNetwork,
1718 /// Add write access to specific paths.
1719 WithWriteAccess(Vec<PathBuf>),
1720 /// Remove sandbox restrictions entirely (dangerous).
1721 FullAccess,
1722 /// Abort the tool execution.
1723 Abort,
1724 }
1725
1726 impl ElevationOption {
1727 /// Get the display label for this option.
1728 #[cfg(test)]
1729 pub fn label(&self) -> &'static str {
1730 match self {
1731 ElevationOption::WithNetwork => "Allow outbound network",
1732 ElevationOption::WithWriteAccess(_) => "Allow extra write access",
1733 ElevationOption::FullAccess => "Full access (filesystem + network)",
1734 ElevationOption::Abort => "Abort",
1735 }
1736 }
1737
1738 /// Get a short description.
1739 #[cfg(test)]
1740 pub fn description(&self) -> &'static str {
1741 match self {
1742 ElevationOption::WithNetwork => {
1743 "Retry this tool call with outbound network access for downloads and HTTP requests"
1744 }
1745 ElevationOption::WithWriteAccess(_) => {
1746 "Retry this tool call with additional writable filesystem scope"
1747 }
1748 ElevationOption::FullAccess => {
1749 "Retry without sandbox limits; grants unrestricted filesystem and network access"
1750 }
1751 ElevationOption::Abort => "Cancel this tool execution",
1752 }
1753 }
1754
1755 /// Convert to a sandbox policy.
1756 pub fn to_policy(&self, base_cwd: &Path) -> SandboxPolicy {
1757 match self {
1758 ElevationOption::WithNetwork => SandboxPolicy::workspace_with_network(),
1759 ElevationOption::WithWriteAccess(paths) => {
1760 let mut roots = paths.clone();
1761 roots.push(base_cwd.to_path_buf());
1762 SandboxPolicy::workspace_with_roots(roots, false)
1763 }
1764 ElevationOption::FullAccess => SandboxPolicy::DangerFullAccess,
1765 ElevationOption::Abort => SandboxPolicy::default(), // Won't be used
1766 }
1767 }
1768 }
1769
1770 /// Request for user decision after a sandbox denial.
1771 #[derive(Debug, Clone)]
1772 pub struct ElevationRequest {
1773 /// The tool ID that was blocked.
1774 pub tool_id: String,
1775 /// The tool name.
1776 pub tool_name: String,
1777 /// The command that was blocked (if shell).
1778 pub command: Option<String>,
1779 /// The reason for denial (from sandbox).
1780 pub denial_reason: String,
1781 /// Available elevation options.
1782 pub options: Vec<ElevationOption>,
1783 }
1784
1785 impl ElevationRequest {
1786 /// Create a new elevation request for a shell command.
1787 pub fn for_shell(
1788 tool_id: &str,
1789 command: &str,
1790 denial_reason: &str,
1791 blocked_network: bool,
1792 blocked_write: bool,
1793 ) -> Self {
1794 let mut options = Vec::new();
1795
1796 if blocked_network {
1797 options.push(ElevationOption::WithNetwork);
1798 }
1799 if blocked_write {
1800 options.push(ElevationOption::WithWriteAccess(vec![]));
1801 }
1802 options.push(ElevationOption::FullAccess);
1803 options.push(ElevationOption::Abort);
1804
1805 Self {
1806 tool_id: tool_id.to_string(),
1807 tool_name: "exec_shell".to_string(),
1808 command: Some(command.to_string()),
1809 denial_reason: denial_reason.to_string(),
1810 options,
1811 }
1812 }
1813
1814 /// Create a generic elevation request.
1815 #[allow(dead_code)]
1816 pub fn generic(tool_id: &str, tool_name: &str, denial_reason: &str) -> Self {
1817 Self {
1818 tool_id: tool_id.to_string(),
1819 tool_name: tool_name.to_string(),
1820 command: None,
1821 denial_reason: denial_reason.to_string(),
1822 options: vec![
1823 ElevationOption::WithNetwork,
1824 ElevationOption::FullAccess,
1825 ElevationOption::Abort,
1826 ],
1827 }
1828 }
1829 }
1830
1831 /// Elevation overlay state managed by the modal view stack.
1832 #[derive(Debug, Clone)]
1833 pub struct ElevationView {
1834 request: ElevationRequest,
1835 selected: usize,
1836 locale: Locale,
1837 row_hitboxes: RefCell<Vec<Rect>>,
1838 }
1839
1840 impl ElevationView {
1841 pub fn new(request: ElevationRequest, locale: Locale) -> Self {
1842 Self {
1843 request,
1844 selected: 0,
1845 locale,
1846 row_hitboxes: RefCell::new(Vec::new()),
1847 }
1848 }
1849
1850 fn select_prev(&mut self) {
1851 self.selected =
1852 crate::tui::list_nav::wrap_index(self.selected, self.request.options.len(), -1);
1853 }
1854
1855 fn select_next(&mut self) {
1856 self.selected =
1857 crate::tui::list_nav::wrap_index(self.selected, self.request.options.len(), 1);
1858 }
1859
1860 fn current_option(&self) -> &ElevationOption {
1861 &self.request.options[self.selected]
1862 }
1863
1864 fn emit_decision(&self, option: ElevationOption) -> ViewAction {
1865 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
1866 tool_id: self.request.tool_id.clone(),
1867 tool_name: self.request.tool_name.clone(),
1868 option,
1869 })
1870 }
1871
1872 /// Get the request for rendering.
1873 #[allow(dead_code)]
1874 pub fn request(&self) -> &ElevationRequest {
1875 &self.request
1876 }
1877
1878 /// Get the currently selected index.
1879 #[allow(dead_code)]
1880 pub fn selected(&self) -> usize {
1881 self.selected
1882 }
1883 }
1884
1885 impl ModalView for ElevationView {
1886 fn kind(&self) -> ModalKind {
1887 ModalKind::Elevation
1888 }
1889
1890 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1891 self
1892 }
1893
1894 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
1895 match key.code {
1896 KeyCode::Up | KeyCode::Char('k') => {
1897 self.select_prev();
1898 ViewAction::None
1899 }
1900 KeyCode::Down | KeyCode::Char('j') => {
1901 self.select_next();
1902 ViewAction::None
1903 }
1904 KeyCode::Enter => self.emit_decision(self.current_option().clone()),
1905 KeyCode::Char('n') => self.emit_decision(ElevationOption::WithNetwork),
1906 KeyCode::Char('w') => {
1907 // Find the write access option if available
1908 for opt in &self.request.options {
1909 if matches!(opt, ElevationOption::WithWriteAccess(_)) {
1910 return self.emit_decision(opt.clone());
1911 }
1912 }
1913 ViewAction::None
1914 }
1915 KeyCode::Char('f') => self.emit_decision(ElevationOption::FullAccess),
1916 KeyCode::Esc | KeyCode::Char('a') => self.emit_decision(ElevationOption::Abort),
1917 _ => ViewAction::None,
1918 }
1919 }
1920
1921 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
1922 match mouse.kind {
1923 MouseEventKind::ScrollUp => {
1924 self.select_prev();
1925 ViewAction::None
1926 }
1927 MouseEventKind::ScrollDown => {
1928 self.select_next();
1929 ViewAction::None
1930 }
1931 MouseEventKind::Down(MouseButton::Left) => {
1932 let clicked = self.row_hitboxes.borrow().iter().position(|rect| {
1933 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
1934 });
1935 if let Some(index) = clicked {
1936 return self.emit_decision(self.request.options[index].clone());
1937 }
1938 ViewAction::None
1939 }
1940 _ => ViewAction::None,
1941 }
1942 }
1943
1944 fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
1945 let elevation_widget = ElevationWidget::new_with_hitboxes(
1946 &self.request,
1947 self.selected,
1948 self.locale,
1949 &self.row_hitboxes,
1950 );
1951 elevation_widget.render(area, buf);
1952 }
1953 }
1954
1955 // ============================================================================
1956 // Tests
1957 // ============================================================================
1958
1959 #[cfg(test)]
1960 mod tests {
1961 use super::policy::get_tool_category;
1962 use super::*;
1963 use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1964 use ratatui::{Terminal, backend::TestBackend};
1965 use serde_json::json;
1966
1967 fn create_key_event(code: KeyCode) -> KeyEvent {
1968 KeyEvent {
1969 code,
1970 modifiers: KeyModifiers::empty(),
1971 kind: crossterm::event::KeyEventKind::Press,
1972 state: crossterm::event::KeyEventState::NONE,
1973 }
1974 }
1975
1976 fn benign_request() -> ApprovalRequest {
1977 ApprovalRequest::new(
1978 "test-id",
1979 "read_file",
1980 "Read a file from disk",
1981 &json!({"path": "src/main.rs"}),
1982 "tool:read_file",
1983 )
1984 }
1985
1986 fn destructive_request() -> ApprovalRequest {
1987 ApprovalRequest::new(
1988 "test-id",
1989 "write_file",
1990 "Write a file to disk",
1991 &json!({"path": "src/main.rs", "content": "test"}),
1992 "tool:write_file",
1993 )
1994 }
1995
1996 fn critical_request() -> ApprovalRequest {
1997 ApprovalRequest::new(
1998 "test-id",
1999 "exec_shell",
2000 "Run a shell command",
2001 &json!({"command": "rm -rf ~/"}),
2002 "tool:exec_shell",
2003 )
2004 }
2005
2006 fn shell_request() -> ApprovalRequest {
2007 ApprovalRequest::new(
2008 "test-id",
2009 "exec_shell",
2010 "Run a shell command",
2011 &json!({"command": "cargo test --workspace"}),
2012 "tool:exec_shell",
2013 )
2014 }
2015
2016 // ========================================================================
2017 // Tool Category Tests
2018 // ========================================================================
2019
2020 #[test]
2021 fn test_get_tool_category_safe_tools() {
2022 assert_eq!(get_tool_category("read_file"), ToolCategory::Safe);
2023 assert_eq!(get_tool_category("list_dir"), ToolCategory::Safe);
2024 assert_eq!(get_tool_category("todo_write"), ToolCategory::Safe);
2025 assert_eq!(get_tool_category("work_update"), ToolCategory::Safe);
2026 assert_eq!(get_tool_category("checklist_write"), ToolCategory::Safe);
2027 assert_eq!(get_tool_category("todo_read"), ToolCategory::Safe);
2028 assert_eq!(get_tool_category("note"), ToolCategory::Safe);
2029 assert_eq!(get_tool_category("update_plan"), ToolCategory::Safe);
2030 }
2031
2032 #[test]
2033 fn test_get_tool_category_file_write_tools() {
2034 assert_eq!(get_tool_category("write_file"), ToolCategory::FileWrite);
2035 assert_eq!(get_tool_category("edit_file"), ToolCategory::FileWrite);
2036 assert_eq!(get_tool_category("apply_patch"), ToolCategory::FileWrite);
2037 }
2038
2039 #[test]
2040 fn test_get_tool_category_shell_tools() {
2041 assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell);
2042 assert_eq!(get_tool_category("task_shell_start"), ToolCategory::Shell);
2043 assert_eq!(get_tool_category("task_shell_wait"), ToolCategory::Shell);
2044 assert_eq!(get_tool_category("exec_shell_wait"), ToolCategory::Shell);
2045 assert_eq!(
2046 get_tool_category("exec_shell_interact"),
2047 ToolCategory::Shell
2048 );
2049 assert_eq!(get_tool_category("exec_wait"), ToolCategory::Shell);
2050 assert_eq!(get_tool_category("exec_interact"), ToolCategory::Shell);
2051 assert_eq!(
2052 get_tool_category("mcp_linear_save_issue"),
2053 ToolCategory::McpAction
2054 );
2055 assert_eq!(
2056 get_tool_category("start_registry_mcp_server"),
2057 ToolCategory::McpAction
2058 );
2059 assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::McpRead);
2060 }
2061
2062 #[test]
2063 fn test_get_tool_category_unknown_tools_need_review() {
2064 assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Unknown);
2065 }
2066
2067 // ========================================================================
2068 // Risk Routing Tests (#129)
2069 // ========================================================================
2070
2071 #[test]
2072 fn risk_safe_categories_route_benign() {
2073 let cat = ToolCategory::Safe;
2074 assert_eq!(
2075 classify_risk("read_file", cat, &json!({"path": "x"})),
2076 RiskLevel::Benign
2077 );
2078 let cat = ToolCategory::McpRead;
2079 assert_eq!(
2080 classify_risk("list_mcp_tools", cat, &json!({})),
2081 RiskLevel::Benign
2082 );
2083 }
2084
2085 #[test]
2086 fn risk_query_only_network_is_benign_but_fetch_is_destructive() {
2087 // web_search is read-only enough to use the benign variant.
2088 let cat = ToolCategory::Network;
2089 assert_eq!(
2090 classify_risk("web_search", cat, &json!({"q": "rust"})),
2091 RiskLevel::Benign
2092 );
2093 // Registry discovery mirrors web_search: query-only network → Benign.
2094 assert_eq!(
2095 classify_risk("registry_sync", cat, &json!({})),
2096 RiskLevel::Benign
2097 );
2098 // fetch_url pulls arbitrary remote content, so it stays destructive.
2099 assert_eq!(
2100 classify_risk("fetch_url", cat, &json!({"url": "https://example.com"})),
2101 RiskLevel::Destructive
2102 );
2103 // wait_for_dev_server only permits loopback targets.
2104 assert_eq!(
2105 classify_risk("wait_for_dev_server", cat, &json!({"port": 5173})),
2106 RiskLevel::Benign
2107 );
2108 }
2109
2110 #[test]
2111 fn risk_writes_shell_mcp_action_unknown_route_destructive() {
2112 for (name, cat) in [
2113 ("write_file", ToolCategory::FileWrite),
2114 ("edit_file", ToolCategory::FileWrite),
2115 ("apply_patch", ToolCategory::FileWrite),
2116 ("exec_shell", ToolCategory::Shell),
2117 ("mcp_linear_save_issue", ToolCategory::McpAction),
2118 ("start_registry_mcp_server", ToolCategory::McpAction),
2119 ("totally_new_tool", ToolCategory::Unknown),
2120 ] {
2121 assert_eq!(
2122 classify_risk(name, cat, &json!({})),
2123 RiskLevel::Destructive,
2124 "expected {name:?} to be Destructive",
2125 );
2126 }
2127 }
2128
2129 #[test]
2130 fn risk_read_only_shell_commands_route_benign() {
2131 let cat = ToolCategory::Shell;
2132 for command in [
2133 "codewhale --version",
2134 "codewhale --help",
2135 "git status --porcelain",
2136 ] {
2137 assert_eq!(
2138 classify_risk("exec_shell", cat, &json!({ "command": command })),
2139 RiskLevel::Benign,
2140 "expected read-only shell command {command:?} to be Benign",
2141 );
2142 }
2143 }
2144
2145 #[test]
2146 fn risk_dangerous_shell_command_stays_destructive() {
2147 // command_safety would flag this as Dangerous; classify_risk
2148 // already routes Shell to Destructive. The check exists so a
2149 // future attempt to relax shell to Benign cannot smuggle this
2150 // through unexamined.
2151 let cat = ToolCategory::Shell;
2152 assert_eq!(
2153 classify_risk("exec_shell", cat, &json!({"command": "rm -rf /"})),
2154 RiskLevel::Destructive
2155 );
2156 }
2157
2158 // ========================================================================
2159 // ApprovalRequest Tests
2160 // ========================================================================
2161
2162 #[test]
2163 fn test_approval_request_new() {
2164 let params = json!({"path": "src/main.rs", "content": "test"});
2165 let request = ApprovalRequest::new(
2166 "test-id",
2167 "write_file",
2168 "Write a file to disk",
2169 &params,
2170 "test_key",
2171 );
2172
2173 assert_eq!(request.id, "test-id");
2174 assert_eq!(request.tool_name, "write_file");
2175 assert_eq!(request.category, ToolCategory::FileWrite);
2176 assert_eq!(request.risk, RiskLevel::Destructive);
2177 assert_eq!(request.params, params);
2178 }
2179
2180 #[test]
2181 fn test_approval_request_params_display_truncates() {
2182 let long_content = "x".repeat(300);
2183 let params = json!({"path": "src/main.rs", "content": long_content});
2184 let request = ApprovalRequest::new(
2185 "test-id",
2186 "write_file",
2187 "Write a file to disk",
2188 &params,
2189 "test_key",
2190 );
2191
2192 let display = request.params_display();
2193 assert!(display.len() < 250);
2194 assert!(display.contains("src/main.rs"));
2195 }
2196
2197 #[test]
2198 fn test_approval_request_params_display_short() {
2199 let params = json!({"path": "src/main.rs"});
2200 let request = ApprovalRequest::new(
2201 "test-id",
2202 "read_file",
2203 "Read a file from disk",
2204 &params,
2205 "test_key",
2206 );
2207
2208 let display = request.params_display();
2209 assert!(display.contains("src/main.rs"));
2210 }
2211
2212 #[test]
2213 fn test_approval_request_derives_impact_summary() {
2214 let params = json!({"cmd": "cargo test", "workdir": "/tmp/project"});
2215 let request = ApprovalRequest::new(
2216 "test-id",
2217 "exec_shell",
2218 "Run a shell command",
2219 &params,
2220 "test_key",
2221 );
2222
2223 assert_eq!(request.category, ToolCategory::Shell);
2224 assert!(
2225 request
2226 .impacts
2227 .iter()
2228 .any(|line| line.contains("Executes a Bash command"))
2229 );
2230 assert!(
2231 request
2232 .impacts
2233 .iter()
2234 .all(|line| !line.contains("cargo test")),
2235 "command detail should not be duplicated in the impact summary"
2236 );
2237 let details = request.prominent_detail_items(Locale::En);
2238 assert!(
2239 details
2240 .iter()
2241 .any(|detail| detail.label == "Command" && detail.value.contains("cargo test"))
2242 );
2243 }
2244
2245 #[test]
2246 fn mcp_impact_summary_preserves_full_target_for_underscored_names() {
2247 let request = ApprovalRequest::new(
2248 "test-id",
2249 "mcp_my_db_execute_sql",
2250 "Call an MCP tool",
2251 &json!({}),
2252 "tool:mcp_my_db_execute_sql",
2253 );
2254
2255 assert!(
2256 request
2257 .impacts
2258 .iter()
2259 .any(|line| line == "MCP target: my_db_execute_sql")
2260 );
2261 assert!(!request.impacts.iter().any(|line| line == "Server: my"));
2262
2263 let zh_impacts = request.impacts_for_locale(Locale::ZhHans);
2264 assert!(
2265 zh_impacts
2266 .iter()
2267 .any(|line| line == "MCP 目标:my_db_execute_sql")
2268 );
2269 assert!(!zh_impacts.iter().any(|line| line == "服务器:my"));
2270 }
2271
2272 #[test]
2273 fn test_prominent_details_shell_does_not_truncate_long_command() {
2274 let command = format!("printf '{}\\n' > /tmp/x && cat /tmp/x", "x".repeat(300));
2275 let request = ApprovalRequest::new(
2276 "test-id",
2277 "exec_shell",
2278 "Run a shell command",
2279 &json!({"command": command, "cwd": "/tmp/project"}),
2280 "test_key",
2281 );
2282
2283 let details = request.prominent_detail_items(Locale::En);
2284
2285 assert_eq!(details[0].label, "Command");
2286 assert_eq!(details[0].value, command);
2287 assert!(
2288 details[0]
2289 .shell_lines
2290 .as_ref()
2291 .is_some_and(|lines| lines.iter().any(|line| line.contains("cat /tmp/x"))),
2292 "shell preview should preserve the dangerous tail of long commands"
2293 );
2294 assert_eq!(details[1].label, "Dir");
2295 assert_eq!(details[1].value, "/tmp/project");
2296 }
2297
2298 #[test]
2299 fn test_prominent_details_file_write() {
2300 let request = ApprovalRequest::new(
2301 "test-id",
2302 "write_file",
2303 "Write a file to disk",
2304 &json!({"path": "src/main.rs", "content": "fn main() {}"}),
2305 "test_key",
2306 );
2307
2308 let details = request.prominent_detail_items(Locale::En);
2309
2310 assert_eq!(details[0].label, "File");
2311 assert_eq!(details[0].value, "src/main.rs");
2312 assert!(details[0].shell_lines.is_none());
2313 assert_eq!(details[1].label, "Preview");
2314 let preview = details[1].shell_lines.as_ref().expect("preview lines");
2315 assert!(preview.iter().any(|line| line == "+ fn main() {}"));
2316 }
2317
2318 #[test]
2319 fn prominent_details_edit_file_includes_search_replace_preview() {
2320 let request = ApprovalRequest::new(
2321 "test-id",
2322 "edit_file",
2323 "Edit a file on disk",
2324 &json!({
2325 "path": "src/lib.rs",
2326 "search": "old_call();",
2327 "replace": "new_call();"
2328 }),
2329 "tool:edit_file",
2330 );
2331
2332 let details = request.prominent_detail_items(Locale::En);
2333 let preview = details
2334 .iter()
2335 .find(|detail| detail.label == "Preview")
2336 .and_then(|detail| detail.shell_lines.as_ref())
2337 .expect("edit preview");
2338
2339 assert!(preview.iter().any(|line| line == "- old_call();"));
2340 assert!(preview.iter().any(|line| line == "+ new_call();"));
2341 }
2342
2343 #[test]
2344 fn prominent_details_apply_patch_includes_diff_preview() {
2345 let patch = r#"diff --git a/src/lib.rs b/src/lib.rs
2346 --- a/src/lib.rs
2347 +++ b/src/lib.rs
2348 @@ -1,2 +1,2 @@
2349 -old
2350 +new
2351 "#;
2352 let request = ApprovalRequest::new(
2353 "test-id",
2354 "apply_patch",
2355 "Apply a patch",
2356 &json!({"patch": patch}),
2357 "tool:apply_patch",
2358 );
2359
2360 let details = request.prominent_detail_items(Locale::En);
2361 let preview = details
2362 .iter()
2363 .find(|detail| detail.label == "Preview")
2364 .and_then(|detail| detail.shell_lines.as_ref())
2365 .expect("patch preview");
2366
2367 assert!(preview.iter().any(|line| line.starts_with("@@")));
2368 assert!(preview.iter().any(|line| line == "-old"));
2369 assert!(preview.iter().any(|line| line == "+new"));
2370 }
2371
2372 #[test]
2373 fn prominent_details_apply_patch_changes_array_preview_stays_bounded() {
2374 let request = ApprovalRequest::new(
2375 "test-id",
2376 "apply_patch",
2377 "Apply a patch",
2378 &json!({
2379 "replace": [
2380 {
2381 "path": "src/lib.rs",
2382 "content": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight"
2383 },
2384 {
2385 "path": "src/main.rs",
2386 "content": "main"
2387 },
2388 {
2389 "path": "src/extra.rs",
2390 "content": "extra"
2391 }
2392 ]
2393 }),
2394 "tool:apply_patch",
2395 );
2396
2397 let details = request.prominent_detail_items(Locale::En);
2398 let preview = details
2399 .iter()
2400 .find(|detail| detail.label == "Preview")
2401 .and_then(|detail| detail.shell_lines.as_ref())
2402 .expect("changes preview");
2403
2404 assert!(
2405 preview.len() <= 7,
2406 "preview should stay bounded: {preview:?}"
2407 );
2408 assert!(preview.iter().any(|line| line == "file: src/lib.rs"));
2409 assert_eq!(
2410 preview.last().map(String::as_str),
2411 Some("... (+2 more files)")
2412 );
2413 }
2414
2415 #[test]
2416 fn prominent_details_apply_patch_legacy_changes_includes_preview() {
2417 let request = ApprovalRequest::new(
2418 "test-id",
2419 "apply_patch",
2420 "Apply a patch",
2421 &json!({
2422 "changes": [{
2423 "path": "src/lib.rs",
2424 "content": "fn legacy() {}\n"
2425 }]
2426 }),
2427 "tool:apply_patch",
2428 );
2429
2430 let details = request.prominent_detail_items(Locale::En);
2431 let preview = details
2432 .iter()
2433 .find(|detail| detail.label == "Preview")
2434 .and_then(|detail| detail.shell_lines.as_ref())
2435 .expect("legacy changes preview");
2436
2437 assert!(preview.iter().any(|line| line == "file: src/lib.rs"));
2438 assert!(preview.iter().any(|line| line == "+ fn legacy() {}"));
2439 }
2440
2441 #[test]
2442 fn apply_patch_changes_array_preview_reports_second_file_when_first_fills_buffer() {
2443 let request = ApprovalRequest::new(
2444 "test-id",
2445 "apply_patch",
2446 "Apply a patch",
2447 &json!({
2448 "replace": [
2449 {
2450 "path": "src/lib.rs",
2451 "content": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight"
2452 },
2453 {
2454 "path": "src/main.rs",
2455 "content": "main"
2456 }
2457 ]
2458 }),
2459 "tool:apply_patch",
2460 );
2461
2462 let details = request.prominent_detail_items(Locale::En);
2463 let preview = details
2464 .iter()
2465 .find(|detail| detail.label == "Preview")
2466 .and_then(|detail| detail.shell_lines.as_ref())
2467 .expect("changes preview");
2468
2469 assert!(
2470 preview.len() <= 7,
2471 "preview should stay bounded: {preview:?}"
2472 );
2473 assert!(preview.iter().any(|line| line == "file: src/lib.rs"));
2474 assert_eq!(
2475 preview.last().map(String::as_str),
2476 Some("... (+1 more files)")
2477 );
2478 }
2479
2480 #[test]
2481 fn apply_patch_preview_counts_omitted_context_lines() {
2482 let patch = r#"diff --git a/src/lib.rs b/src/lib.rs
2483 --- a/src/lib.rs
2484 +++ b/src/lib.rs
2485 @@ -1,8 +1,8 @@
2486 context one
2487 context two
2488 -old
2489 +new
2490 context three
2491 context four
2492 context five
2493 "#;
2494
2495 let preview = apply_patch_preview_lines(patch).expect("patch preview");
2496
2497 assert!(
2498 preview.len() <= 7,
2499 "preview should stay bounded: {preview:?}"
2500 );
2501 assert_eq!(
2502 preview.last().map(String::as_str),
2503 Some("... (+5 more patch lines)")
2504 );
2505 }
2506
2507 #[test]
2508 fn apply_patch_preview_counts_replaced_visible_line_as_omitted() {
2509 let patch = r#"diff --git a/src/lib.rs b/src/lib.rs
2510 --- a/src/lib.rs
2511 +++ b/src/lib.rs
2512 @@ -1,4 +1,4 @@
2513 -old1
2514 +new1
2515 -old2
2516 +new2
2517 context one
2518 context two
2519 "#;
2520
2521 let preview = apply_patch_preview_lines(patch).expect("patch preview");
2522
2523 assert_eq!(preview.len(), 7);
2524 assert_eq!(
2525 preview.last().map(String::as_str),
2526 Some("... (+4 more patch lines)")
2527 );
2528 }
2529
2530 #[test]
2531 fn preview_sublabels_are_localized_for_zh_hans() {
2532 let write = ApprovalRequest::new(
2533 "test-id",
2534 "write_file",
2535 "Write a file",
2536 &json!({"path": "src/lib.rs", "content": "proposed content\nreplacement content"}),
2537 "tool:write_file",
2538 );
2539 let write_preview = write
2540 .prominent_detail_items(Locale::ZhHans)
2541 .into_iter()
2542 .find(|detail| detail.label == "预览")
2543 .and_then(|detail| detail.shell_lines)
2544 .expect("localized write preview");
2545 assert!(write_preview.iter().any(|line| line == "拟写入内容"));
2546 assert!(
2547 write_preview
2548 .iter()
2549 .any(|line| line == "+ proposed content")
2550 );
2551 assert!(
2552 write_preview
2553 .iter()
2554 .any(|line| line == "+ replacement content")
2555 );
2556
2557 let edit = ApprovalRequest::new(
2558 "test-id",
2559 "edit_file",
2560 "Edit a file",
2561 &json!({
2562 "path": "src/lib.rs",
2563 "search": "with this",
2564 "replace": "replace this"
2565 }),
2566 "tool:edit_file",
2567 );
2568 let edit_preview = edit
2569 .prominent_detail_items(Locale::ZhHans)
2570 .into_iter()
2571 .find(|detail| detail.label == "预览")
2572 .and_then(|detail| detail.shell_lines)
2573 .expect("localized edit preview");
2574 assert!(edit_preview.iter().any(|line| line == "替换此内容"));
2575 assert!(edit_preview.iter().any(|line| line == "替换为"));
2576 assert!(edit_preview.iter().any(|line| line == "- with this"));
2577 assert!(edit_preview.iter().any(|line| line == "+ replace this"));
2578 }
2579
2580 #[test]
2581 fn test_shell_formatter_preserves_logical_or_operator() {
2582 let lines = format_shell_command_for_approval("cargo build || echo fallback");
2583
2584 assert_eq!(lines, vec!["cargo build ||", "echo fallback"]);
2585 }
2586
2587 #[test]
2588 fn test_shell_formatter_detects_printf_write_file_preview() {
2589 let lines =
2590 format_shell_command_for_approval("printf '%s\\n' 'hello' 'world' > src/main.rs");
2591
2592 assert_eq!(lines[0], "printf > src/main.rs");
2593 assert!(lines.iter().any(|line| line.contains("hello")));
2594 assert!(lines.iter().any(|line| line.contains("world")));
2595 }
2596
2597 // ========================================================================
2598 // ApprovalView Tests — Benign Variant (single-key approve)
2599 // ========================================================================
2600
2601 #[test]
2602 fn test_approval_view_initial_state() {
2603 let view = ApprovalView::new(benign_request());
2604 assert_eq!(view.current_option(), ApprovalOption::Deny);
2605 assert!(view.timeout.is_none());
2606 assert_eq!(view.risk(), RiskLevel::Benign);
2607 }
2608
2609 #[test]
2610 fn exec_shell_request_builds_ask_rule_preview() {
2611 let request = shell_request();
2612
2613 assert_eq!(
2614 request.persistent_ask_rules,
2615 vec![ToolAskRule::exec_shell("cargo test --workspace")]
2616 );
2617 let preview = request.ask_rule_preview().expect("preview");
2618 assert!(preview.contains("[[rules]]"));
2619 assert!(preview.contains("tool = \"exec_shell\""));
2620 assert!(preview.contains("command = \"cargo test --workspace\""));
2621 }
2622
2623 #[test]
2624 fn ask_rule_save_preview_formats_shell_rule() {
2625 let request = shell_request();
2626
2627 let preview = request.ask_rule_save_preview().expect("save preview");
2628 assert_eq!(preview.rule_count, 1);
2629 assert_eq!(preview.summary(), "1 ask rule");
2630 assert_eq!(
2631 preview.entries,
2632 vec!["tool=exec_shell command=cargo test --workspace"]
2633 );
2634 assert_eq!(preview.omitted, 0);
2635 }
2636
2637 #[test]
2638 fn safe_shell_request_builds_exact_workspace_allow_rule() {
2639 let request = shell_request();
2640 let expected = ToolAskRule::exec_shell("cargo test --workspace")
2641 .into_exact_workspace_allow("/workspace");
2642
2643 assert!(request.can_save_allow_rule());
2644 assert_eq!(request.persistent_allow_rules, vec![expected]);
2645 let preview = request.allow_rule_save_preview().expect("allow preview");
2646 assert_eq!(preview.summary(), "1 allow rule");
2647 assert_eq!(
2648 preview.entries,
2649 vec![
2650 "tool=exec_shell command=cargo test --workspace command_exact=true workspace=/workspace"
2651 ]
2652 );
2653 }
2654
2655 #[test]
2656 fn unsafe_shell_requests_cannot_persist_allow_rules() {
2657 for command in [
2658 "rm -rf ~/",
2659 "git push origin main",
2660 "curl https://example.com",
2661 "cargo test && git status",
2662 ] {
2663 let request = ApprovalRequest::new(
2664 "test-id",
2665 "exec_shell",
2666 "Run a shell command",
2667 &json!({"command": command}),
2668 "tool:exec_shell",
2669 );
2670 assert!(
2671 request.persistent_allow_rules.is_empty(),
2672 "{command:?} must not produce a remembered allow grant"
2673 );
2674 assert!(!request.can_save_allow_rule(), "{command:?}");
2675 assert_eq!(request.allow_rule_save_preview(), None, "{command:?}");
2676 }
2677 }
2678
2679 #[test]
2680 fn file_ask_rule_saved_for_write_file_approval() {
2681 // A write_file approval offers an exact, workspace-relative file rule
2682 // plus a preview so `S` can persist it.
2683 let request = destructive_request();
2684
2685 assert_eq!(
2686 request.persistent_ask_rules,
2687 vec![ToolAskRule::file_path("write_file", "src/main.rs")]
2688 );
2689 assert!(request.can_save_ask_rule());
2690 let preview = request.ask_rule_preview().expect("preview");
2691 assert!(preview.contains("[[rules]]"));
2692 assert!(preview.contains("tool = \"write_file\""));
2693 assert!(preview.contains("path = \"src/main.rs\""));
2694 }
2695
2696 #[test]
2697 fn file_write_builds_exact_workspace_allow_rule() {
2698 let request = destructive_request();
2699 let expected = ToolAskRule::file_path("write_file", "src/main.rs")
2700 .into_exact_workspace_allow("/workspace");
2701
2702 assert!(request.can_save_allow_rule());
2703 assert_eq!(request.persistent_allow_rules, vec![expected]);
2704 assert_eq!(
2705 request
2706 .allow_rule_save_preview()
2707 .expect("allow preview")
2708 .entries,
2709 vec!["tool=write_file path=src/main.rs workspace=/workspace"]
2710 );
2711 }
2712
2713 #[test]
2714 fn ask_rule_save_preview_formats_write_and_edit_file_paths() {
2715 let write = destructive_request();
2716 let edit = ApprovalRequest::new(
2717 "test-id",
2718 "edit_file",
2719 "Edit a file on disk",
2720 &json!({"path": "/workspace/src/lib.rs"}),
2721 "tool:edit_file",
2722 );
2723
2724 assert_eq!(
2725 write
2726 .ask_rule_save_preview()
2727 .expect("write save preview")
2728 .entries,
2729 vec!["tool=write_file path=src/main.rs"]
2730 );
2731 assert_eq!(
2732 edit.ask_rule_save_preview()
2733 .expect("edit save preview")
2734 .entries,
2735 vec!["tool=edit_file path=src/lib.rs"]
2736 );
2737 }
2738
2739 #[test]
2740 fn file_ask_rule_normalizes_absolute_edit_file_path_to_workspace_relative() {
2741 // An absolute in-workspace path is stored in the workspace-relative
2742 // form, matching how runtime ask-rule matching normalizes paths.
2743 let request = ApprovalRequest::new(
2744 "test-id",
2745 "edit_file",
2746 "Edit a file on disk",
2747 &json!({"path": "/workspace/src/lib.rs"}),
2748 "tool:edit_file",
2749 );
2750
2751 assert_eq!(
2752 request.persistent_ask_rules,
2753 vec![ToolAskRule::file_path("edit_file", "src/lib.rs")]
2754 );
2755 }
2756
2757 #[test]
2758 fn read_file_request_has_no_file_ask_rule() {
2759 // The save boundary is write approvals only; read_file never offers a
2760 // persistent rule.
2761 let request = benign_request();
2762
2763 assert!(request.persistent_ask_rules.is_empty());
2764 assert!(!request.can_save_ask_rule());
2765 assert_eq!(request.ask_rule_preview(), None);
2766 assert_eq!(request.ask_rule_save_preview(), None);
2767 }
2768
2769 #[test]
2770 fn file_ask_rule_skipped_for_unsafe_empty_or_external_paths() {
2771 // Traversal, empty, and outside-workspace paths must not become rules,
2772 // so the preview and `S` shortcut stay disabled.
2773 for path in ["../escape.rs", "/etc/passwd", " ", ""] {
2774 let request = ApprovalRequest::new(
2775 "test-id",
2776 "write_file",
2777 "Write a file to disk",
2778 &json!({"path": path}),
2779 "tool:write_file",
2780 );
2781 assert!(
2782 request.persistent_ask_rules.is_empty(),
2783 "path {path:?} must not produce a rule"
2784 );
2785 assert!(!request.can_save_ask_rule());
2786 assert_eq!(request.ask_rule_preview(), None);
2787 assert_eq!(request.ask_rule_save_preview(), None);
2788 }
2789 }
2790
2791 #[test]
2792 fn apply_patch_ask_rules_saved_for_multi_file_patch() {
2793 let patch = r"diff --git a/src/a.rs b/src/a.rs
2794 --- a/src/a.rs
2795 +++ b/src/a.rs
2796 @@ -1,1 +1,1 @@
2797 -old
2798 +new
2799 diff --git a/src/b.rs b/src/b.rs
2800 --- a/src/b.rs
2801 +++ b/src/b.rs
2802 @@ -1,1 +1,1 @@
2803 -old
2804 +new
2805 ";
2806
2807 let request = ApprovalRequest::new(
2808 "test-id",
2809 "apply_patch",
2810 "Apply a patch",
2811 &json!({"patch": patch}),
2812 "tool:apply_patch",
2813 );
2814
2815 assert_eq!(
2816 request.persistent_ask_rules,
2817 vec![
2818 ToolAskRule::file_path("apply_patch", "src/a.rs"),
2819 ToolAskRule::file_path("apply_patch", "src/b.rs"),
2820 ]
2821 );
2822 assert!(request.can_save_ask_rule());
2823 let preview = request.ask_rule_save_preview().expect("save preview");
2824 assert_eq!(preview.summary(), "2 ask rules");
2825 assert_eq!(
2826 preview.entries,
2827 vec![
2828 "tool=apply_patch path=src/a.rs",
2829 "tool=apply_patch path=src/b.rs"
2830 ]
2831 );
2832 assert_eq!(
2833 request.persistent_allow_rules,
2834 vec![
2835 ToolAskRule::file_path("apply_patch", "src/a.rs")
2836 .into_exact_workspace_allow("/workspace"),
2837 ToolAskRule::file_path("apply_patch", "src/b.rs")
2838 .into_exact_workspace_allow("/workspace"),
2839 ]
2840 );
2841 }
2842
2843 #[test]
2844 fn apply_patch_ask_rules_dedupe_targets_after_normalization() {
2845 let request = ApprovalRequest::new(
2846 "test-id",
2847 "apply_patch",
2848 "Apply a patch",
2849 &json!({
2850 "replace": [
2851 { "path": "src/a.rs", "content": "one" },
2852 { "path": "/workspace/src/a.rs", "content": "two" }
2853 ]
2854 }),
2855 "tool:apply_patch",
2856 );
2857
2858 assert_eq!(
2859 request.persistent_ask_rules,
2860 vec![ToolAskRule::file_path("apply_patch", "src/a.rs")]
2861 );
2862 }
2863
2864 #[test]
2865 fn apply_patch_ask_rule_handles_timestamp_headers() {
2866 let patch = "diff --git a/src/lib.rs b/src/lib.rs\n\
2867 --- a/src/lib.rs\t2026-06-26 10:00:00 +0000\n\
2868 +++ b/src/lib.rs\t2026-06-26 10:01:00 +0000\n\
2869 @@ -1,1 +1,1 @@\n\
2870 -old\n\
2871 +new\n";
2872
2873 let request = ApprovalRequest::new(
2874 "test-id",
2875 "apply_patch",
2876 "Apply a patch",
2877 &json!({"patch": patch}),
2878 "tool:apply_patch",
2879 );
2880
2881 assert_eq!(
2882 request.persistent_ask_rules,
2883 vec![ToolAskRule::file_path("apply_patch", "src/lib.rs")]
2884 );
2885 }
2886
2887 #[test]
2888 fn apply_patch_ask_rule_ignores_forged_headers_inside_hunk() {
2889 let patch = r"--- a/src/lib.rs
2890 +++ b/src/lib.rs
2891 @@ -1,3 +1,3 @@
2892 line1
2893 --- a/forged.rs
2894 +++ b/forged.rs
2895 line3
2896 ";
2897
2898 let request = ApprovalRequest::new(
2899 "test-id",
2900 "apply_patch",
2901 "Apply a patch",
2902 &json!({"path": "src/lib.rs", "patch": patch}),
2903 "tool:apply_patch",
2904 );
2905
2906 assert_eq!(
2907 request.persistent_ask_rules,
2908 vec![ToolAskRule::file_path("apply_patch", "src/lib.rs")]
2909 );
2910 }
2911
2912 #[test]
2913 fn apply_patch_ask_rule_skipped_when_any_target_traverses_workspace() {
2914 let request = ApprovalRequest::new(
2915 "test-id",
2916 "apply_patch",
2917 "Apply a patch",
2918 &json!({
2919 "replace": [
2920 { "path": "src/a.rs", "content": "safe" },
2921 { "path": "../escape.rs", "content": "unsafe" }
2922 ]
2923 }),
2924 "tool:apply_patch",
2925 );
2926
2927 assert!(request.persistent_ask_rules.is_empty());
2928 assert!(!request.can_save_ask_rule());
2929 assert_eq!(request.ask_rule_save_preview(), None);
2930 }
2931
2932 #[test]
2933 fn apply_patch_ask_rule_skipped_on_preflight_failure() {
2934 let request = ApprovalRequest::new(
2935 "test-id",
2936 "apply_patch",
2937 "Apply a patch",
2938 &json!({"patch": "@@ -1 +1 @@\n-old\n+new\n"}),
2939 "tool:apply_patch",
2940 );
2941
2942 assert!(request.persistent_ask_rules.is_empty());
2943 assert_eq!(request.ask_rule_preview(), None);
2944 assert_eq!(request.ask_rule_save_preview(), None);
2945 }
2946
2947 #[test]
2948 fn ask_rule_save_preview_truncates_rule_list() {
2949 let rules = vec![
2950 ToolAskRule::file_path("apply_patch", "src/a.rs"),
2951 ToolAskRule::file_path("apply_patch", "src/b.rs"),
2952 ToolAskRule::file_path("apply_patch", "src/c.rs"),
2953 ToolAskRule::file_path("apply_patch", "src/d.rs"),
2954 ];
2955
2956 let preview = build_permission_rule_save_preview(&rules, 2).expect("save preview");
2957 assert_eq!(preview.rule_count, 4);
2958 assert_eq!(preview.summary(), "4 ask rules");
2959 assert_eq!(
2960 preview.entries,
2961 vec![
2962 "tool=apply_patch path=src/a.rs",
2963 "tool=apply_patch path=src/b.rs"
2964 ]
2965 );
2966 assert_eq!(preview.omitted, 2);
2967 }
2968
2969 #[test]
2970 fn tab_toggles_collapsed_card_so_transcript_stays_visible() {
2971 // Regression for PR #1455 / @tiger-dog: the approval modal once hid
2972 // the transcript, so users had to dismiss the prompt to remember what
2973 // they were approving. Tab flips between the expanded compact card
2974 // and a single-line bottom banner.
2975 let mut view = ApprovalView::new(benign_request());
2976 assert!(
2977 !view.collapsed,
2978 "modal must start expanded so first-time users notice it"
2979 );
2980
2981 let action = view.handle_key(create_key_event(KeyCode::Tab));
2982 assert!(matches!(action, ViewAction::None));
2983 assert!(view.collapsed, "first Tab collapses the card");
2984
2985 let action = view.handle_key(create_key_event(KeyCode::Tab));
2986 assert!(matches!(action, ViewAction::None));
2987 assert!(!view.collapsed, "second Tab restores the expanded card");
2988 }
2989
2990 #[test]
2991 fn test_approval_view_navigation() {
2992 let mut view = ApprovalView::new(benign_request());
2993 assert_eq!(view.current_option(), ApprovalOption::Deny);
2994
2995 view.select_next();
2996 assert_eq!(view.current_option(), ApprovalOption::Abort);
2997 view.select_next();
2998 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
2999 view.select_next();
3000 assert_eq!(view.current_option(), ApprovalOption::ApproveAlways);
3001
3002 // Continue through the semantic default rather than dead-ending (#4755).
3003 view.select_next();
3004 assert_eq!(view.current_option(), ApprovalOption::Deny);
3005
3006 // And back through the same order the other way.
3007 view.select_prev();
3008 assert_eq!(view.current_option(), ApprovalOption::ApproveAlways);
3009
3010 view.select_prev();
3011 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
3012 }
3013
3014 #[test]
3015 fn benign_y_one_step_approves() {
3016 for code in [KeyCode::Char('y'), KeyCode::Char('Y')] {
3017 let mut view = ApprovalView::new(benign_request());
3018 let action = view.handle_key(create_key_event(code));
3019 assert!(
3020 matches!(
3021 action,
3022 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3023 decision: ReviewDecision::Approved,
3024 ..
3025 })
3026 ),
3027 "expected Approved for {code:?}"
3028 );
3029 }
3030 }
3031
3032 #[test]
3033 fn save_ask_rule_shortcut_approves_once_with_rule() {
3034 let mut view = ApprovalView::new(shell_request());
3035
3036 let action = view.handle_key(create_key_event(KeyCode::Char('s')));
3037 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3038 decision,
3039 persistent_rules,
3040 ..
3041 }) = action
3042 else {
3043 panic!("expected approval decision");
3044 };
3045
3046 assert_eq!(decision, ReviewDecision::Approved);
3047 assert_eq!(
3048 persistent_rules,
3049 vec![ToolAskRule::exec_shell("cargo test --workspace")]
3050 );
3051 }
3052
3053 #[test]
3054 fn save_file_ask_rule_shortcut_emits_file_rule() {
3055 // `S` on a write_file approval approves once and carries the exact
3056 // workspace-relative file rule for persistence.
3057 let mut view = ApprovalView::new(destructive_request());
3058
3059 let action = view.handle_key(create_key_event(KeyCode::Char('S')));
3060 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3061 decision,
3062 persistent_rules,
3063 ..
3064 }) = action
3065 else {
3066 panic!("expected approval decision");
3067 };
3068
3069 assert_eq!(decision, ReviewDecision::Approved);
3070 assert_eq!(
3071 persistent_rules,
3072 vec![ToolAskRule::file_path("write_file", "src/main.rs")]
3073 );
3074 }
3075
3076 #[test]
3077 fn persistent_allow_option_approves_once_with_exact_repo_rule() {
3078 let mut view = ApprovalView::new(shell_request());
3079
3080 let action = view.handle_key(create_key_event(KeyCode::Char('p')));
3081 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3082 decision,
3083 persistent_rules,
3084 ..
3085 }) = action
3086 else {
3087 panic!("expected approval decision");
3088 };
3089
3090 assert_eq!(decision, ReviewDecision::Approved);
3091 assert_eq!(
3092 persistent_rules,
3093 vec![
3094 ToolAskRule::exec_shell("cargo test --workspace")
3095 .into_exact_workspace_allow("/workspace")
3096 ]
3097 );
3098 }
3099
3100 #[test]
3101 fn persistent_allow_shortcut_is_ignored_for_dangerous_command() {
3102 let request = critical_request();
3103 assert!(request.persistent_allow_rules.is_empty());
3104 let mut view = ApprovalView::new(request);
3105
3106 assert!(matches!(
3107 view.handle_key(create_key_event(KeyCode::Char('p'))),
3108 ViewAction::None
3109 ));
3110 }
3111
3112 #[test]
3113 fn repo_law_request_does_not_build_a_persistent_allow_candidate() {
3114 let request = ApprovalRequest::new(
3115 "test-id",
3116 "edit_file",
3117 "Repo law holds this write: protected path (matched Cargo.toml, .codewhale/constitution.json)",
3118 &json!({"path": "Cargo.toml", "old": "a", "new": "b"}),
3119 "tool:edit_file",
3120 );
3121
3122 assert!(request.is_repo_law_prompt());
3123 assert!(request.persistent_allow_rules.is_empty());
3124 assert!(!request.can_save_allow_rule());
3125 assert_eq!(request.allow_rule_save_preview(), None);
3126 }
3127
3128 #[test]
3129 fn save_ask_rule_shortcut_is_ignored_without_rule() {
3130 let mut view = ApprovalView::new(benign_request());
3131
3132 let action = view.handle_key(create_key_event(KeyCode::Char('s')));
3133
3134 assert!(matches!(action, ViewAction::None));
3135 }
3136
3137 #[test]
3138 fn benign_one_key_approves_via_numeric_pad() {
3139 let mut view = ApprovalView::new(benign_request());
3140 let action = view.handle_key(create_key_event(KeyCode::Char('1')));
3141 assert!(matches!(
3142 action,
3143 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3144 decision: ReviewDecision::Approved,
3145 ..
3146 })
3147 ));
3148 }
3149
3150 #[test]
3151 fn benign_enter_denies_by_default() {
3152 let mut view = ApprovalView::new(benign_request());
3153 let action = view.handle_key(create_key_event(KeyCode::Enter));
3154 assert!(matches!(
3155 action,
3156 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3157 decision: ReviewDecision::Denied,
3158 ..
3159 })
3160 ));
3161 }
3162
3163 #[test]
3164 fn mouse_click_renders_and_approves_inline_option() {
3165 let mut view = ApprovalView::new(benign_request());
3166 let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal");
3167 terminal
3168 .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
3169 .expect("render approval prompt");
3170 let rect = view.row_hitboxes.borrow()[0];
3171 let action = view.handle_mouse(MouseEvent {
3172 kind: MouseEventKind::Down(MouseButton::Left),
3173 column: rect.x,
3174 row: rect.y,
3175 modifiers: KeyModifiers::NONE,
3176 });
3177 assert!(matches!(
3178 action,
3179 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3180 decision: ReviewDecision::Approved,
3181 ..
3182 })
3183 ));
3184 }
3185
3186 #[test]
3187 fn tiny_localized_approval_keeps_every_action_and_hitbox() {
3188 const WIDTH: u16 = 40;
3189 const HEIGHT: u16 = 12;
3190 let expected = [
3191 ReviewDecision::Approved,
3192 ReviewDecision::ApprovedForSession,
3193 ReviewDecision::Approved,
3194 ReviewDecision::Denied,
3195 ReviewDecision::Abort,
3196 ];
3197
3198 for &locale in Locale::shipped() {
3199 let rendered_view = ApprovalView::new_for_locale(destructive_request(), locale);
3200 let rendered = render_lines(&rendered_view, WIDTH, HEIGHT).join("\n");
3201 assert_approval_key_badges_visible(&rendered);
3202 assert!(
3203 rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
3204 "missing details chord for {locale:?}:\n{rendered}"
3205 );
3206
3207 for (index, expected_decision) in expected.iter().enumerate() {
3208 let mut view = ApprovalView::new_for_locale(destructive_request(), locale);
3209 let mut terminal =
3210 Terminal::new(TestBackend::new(WIDTH, HEIGHT)).expect("test terminal");
3211 terminal
3212 .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
3213 .expect("render localized approval prompt");
3214
3215 let hitboxes = view.row_hitboxes.borrow().clone();
3216 assert_eq!(hitboxes.len(), expected.len(), "{locale:?}: {hitboxes:?}");
3217 for hitbox in &hitboxes {
3218 assert!(hitbox.height > 0, "{locale:?}: {hitboxes:?}");
3219 assert!(hitbox.right() <= WIDTH, "{locale:?}: {hitboxes:?}");
3220 assert!(hitbox.bottom() <= HEIGHT, "{locale:?}: {hitboxes:?}");
3221 }
3222 for pair in hitboxes.windows(2) {
3223 assert!(pair[0].bottom() <= pair[1].y, "{locale:?}: {hitboxes:?}");
3224 }
3225
3226 let rect = hitboxes[index];
3227 let action = view.handle_mouse(MouseEvent {
3228 kind: MouseEventKind::Down(MouseButton::Left),
3229 column: rect.x,
3230 row: rect.y,
3231 modifiers: KeyModifiers::NONE,
3232 });
3233 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { decision, .. }) = action
3234 else {
3235 panic!("click {index} did not decide for {locale:?}");
3236 };
3237 assert_eq!(decision, *expected_decision, "{locale:?} option {index}");
3238 }
3239 }
3240 }
3241
3242 #[test]
3243 fn benign_a_two_approves_for_session() {
3244 for code in [KeyCode::Char('a'), KeyCode::Char('A'), KeyCode::Char('2')] {
3245 let mut view = ApprovalView::new(benign_request());
3246 let action = view.handle_key(create_key_event(code));
3247 assert!(
3248 matches!(
3249 action,
3250 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3251 decision: ReviewDecision::ApprovedForSession,
3252 ..
3253 })
3254 ),
3255 "expected ApprovedForSession for {code:?}"
3256 );
3257 }
3258 }
3259
3260 #[test]
3261 fn benign_n_d_three_all_deny() {
3262 for code in [
3263 KeyCode::Char('n'),
3264 KeyCode::Char('N'),
3265 KeyCode::Char('d'),
3266 KeyCode::Char('D'),
3267 KeyCode::Char('3'),
3268 ] {
3269 let mut view = ApprovalView::new(benign_request());
3270 let action = view.handle_key(create_key_event(code));
3271 assert!(
3272 matches!(
3273 action,
3274 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3275 decision: ReviewDecision::Denied,
3276 ..
3277 })
3278 ),
3279 "expected Denied for {code:?}"
3280 );
3281 }
3282 }
3283
3284 #[test]
3285 fn benign_esc_aborts() {
3286 let mut view = ApprovalView::new(benign_request());
3287 let action = view.handle_key(create_key_event(KeyCode::Esc));
3288 assert!(matches!(
3289 action,
3290 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3291 decision: ReviewDecision::Abort,
3292 ..
3293 })
3294 ));
3295 }
3296
3297 #[test]
3298 fn test_approval_view_enter_uses_selected_option() {
3299 let mut view = ApprovalView::new(benign_request());
3300
3301 // The semantic default is Deny; navigate once to Abort and commit it.
3302 view.select_next();
3303 assert_eq!(view.current_option(), ApprovalOption::Abort);
3304
3305 let action = view.handle_key(create_key_event(KeyCode::Enter));
3306 assert!(matches!(
3307 action,
3308 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3309 decision: ReviewDecision::Abort,
3310 ..
3311 })
3312 ));
3313 }
3314
3315 #[test]
3316 fn test_approval_view_navigation_keys() {
3317 let mut view = ApprovalView::new(benign_request());
3318
3319 view.handle_key(create_key_event(KeyCode::Up));
3320 assert_eq!(view.current_option(), ApprovalOption::ApproveAlways);
3321
3322 view.handle_key(create_key_event(KeyCode::Down));
3323 assert_eq!(view.current_option(), ApprovalOption::Deny);
3324
3325 view.handle_key(create_key_event(KeyCode::Down));
3326 assert_eq!(view.current_option(), ApprovalOption::Abort);
3327
3328 view.handle_key(create_key_event(KeyCode::Char('j')));
3329 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
3330
3331 view.handle_key(create_key_event(KeyCode::Char('k')));
3332 assert_eq!(view.current_option(), ApprovalOption::Abort);
3333 }
3334
3335 #[test]
3336 fn test_approval_view_view_params() {
3337 // Bare `v` must not open details (TUI-DOG-002).
3338 let mut view = ApprovalView::new(benign_request());
3339 let action = view.handle_key(create_key_event(KeyCode::Char('v')));
3340 assert!(matches!(action, ViewAction::None));
3341
3342 let mut view = ApprovalView::new(benign_request());
3343 let action = view.handle_key(create_key_event(KeyCode::Char('V')));
3344 assert!(matches!(action, ViewAction::None));
3345
3346 // Alt+V / Option+V opens the params pager.
3347 let mut view = ApprovalView::new(benign_request());
3348 let action = view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
3349 assert!(matches!(
3350 action,
3351 ViewAction::Emit(ViewEvent::OpenTextPager { .. })
3352 ));
3353 }
3354
3355 #[test]
3356 fn edit_file_details_pager_includes_complete_search_replace_preview() {
3357 let request = ApprovalRequest::new(
3358 "test-id",
3359 "edit_file",
3360 "Edit a file on disk",
3361 &json!({
3362 "path": "src/lib.rs",
3363 "search": " old_1();\r\n\told_2();\nold 3();\nold_4();\nold_5();\n",
3364 "replace": "\tnew_1();\nnew 2();\r\nnew_3();\nnew_4();\nnew_5();"
3365 }),
3366 "tool:edit_file",
3367 );
3368 let mut view = ApprovalView::new(request);
3369
3370 let action = view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
3371 let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) = action else {
3372 panic!("Alt+V should open the edit details pager");
3373 };
3374
3375 let expected_preview = [
3376 "Preview:",
3377 "replace this",
3378 "- \"\\x20\\x20old_1();\\r\\n\"",
3379 "- \"\\told_2();\\n\"",
3380 "- \"old\\x20\\x203();\\n\"",
3381 "- \"old_4();\\n\"",
3382 "- \"old_5();\\n\"",
3383 "with this",
3384 "+ \"\\tnew_1();\\n\"",
3385 "+ \"new\\x20\\x202();\\r\\n\"",
3386 "+ \"new_3();\\n\"",
3387 "+ \"new_4();\\n\"",
3388 "+ \"new_5();\"",
3389 ]
3390 .join("\n");
3391 assert!(
3392 content.contains(&expected_preview),
3393 "details pager omitted part of the edit preview:\n{content}"
3394 );
3395
3396 let pager = crate::tui::pager::PagerView::from_text("Tool Params", &content, 200);
3397 let displayed = pager.body_text();
3398 assert!(
3399 displayed.contains(&expected_preview),
3400 "details pager display changed exact whitespace or line endings:\n{displayed}"
3401 );
3402 }
3403
3404 #[test]
3405 fn edit_file_details_pager_localizes_preview_headers_for_every_locale() {
3406 for &locale in Locale::shipped() {
3407 let request = ApprovalRequest::new(
3408 "test-id",
3409 "edit_file",
3410 "Edit a file on disk",
3411 &json!({
3412 "path": "src/lib.rs",
3413 "search": "old();",
3414 "replace": "new();"
3415 }),
3416 "tool:edit_file",
3417 );
3418 let mut view = ApprovalView::new_for_locale(request, locale);
3419
3420 let action = view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
3421 let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) = action else {
3422 panic!("Alt+V should open the edit details pager for {locale:?}");
3423 };
3424 let expected_headers = format!(
3425 "{}:\n{}\n- \"old();\"\n{}\n+ \"new();\"",
3426 tr(locale, MessageId::ApprovalLabelPreview),
3427 tr(locale, MessageId::ApprovalLabelReplaceThis),
3428 tr(locale, MessageId::ApprovalLabelWithThis),
3429 );
3430
3431 assert!(
3432 content.contains(&expected_headers),
3433 "details pager did not localize edit preview headers for {locale:?}:\n{content}"
3434 );
3435 }
3436 }
3437
3438 #[test]
3439 fn test_approval_view_current_decision_mapping() {
3440 let mut view = ApprovalView::new(benign_request());
3441
3442 view.selected = 0;
3443 assert_eq!(view.current_decision(), ReviewDecision::Approved);
3444 view.selected = 1;
3445 assert_eq!(view.current_decision(), ReviewDecision::ApprovedForSession);
3446 view.selected = 2;
3447 assert_eq!(view.current_decision(), ReviewDecision::Denied);
3448 view.selected = 3;
3449 assert_eq!(view.current_decision(), ReviewDecision::Abort);
3450 }
3451
3452 // ========================================================================
3453 // ApprovalView Tests — Destructive Variant (one-step approve with warning)
3454 // ========================================================================
3455
3456 #[test]
3457 fn destructive_request_routes_destructive() {
3458 let view = ApprovalView::new(destructive_request());
3459 assert_eq!(view.risk(), RiskLevel::Destructive);
3460 }
3461
3462 #[test]
3463 fn destructive_y_first_press_approves_once() {
3464 for code in [KeyCode::Char('y'), KeyCode::Char('Y')] {
3465 let mut view = ApprovalView::new(destructive_request());
3466
3467 let action = view.handle_key(create_key_event(code));
3468 assert!(
3469 matches!(
3470 action,
3471 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3472 decision: ReviewDecision::Approved,
3473 ..
3474 })
3475 ),
3476 "expected Approved for {code:?}"
3477 );
3478 }
3479 }
3480
3481 #[test]
3482 fn destructive_enter_denies_by_default() {
3483 let mut view = ApprovalView::new(destructive_request());
3484
3485 // The persistent-allow row changes numeric indices, but the semantic
3486 // default still starts at Deny.
3487 assert_eq!(view.current_option(), ApprovalOption::Deny);
3488 let action = view.handle_key(create_key_event(KeyCode::Enter));
3489 assert!(matches!(
3490 action,
3491 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3492 decision: ReviewDecision::Denied,
3493 ..
3494 })
3495 ));
3496 }
3497
3498 #[test]
3499 fn destructive_navigation_then_enter_commits_highlighted_abort() {
3500 let mut view = ApprovalView::new(destructive_request());
3501
3502 view.handle_key(create_key_event(KeyCode::Down));
3503 assert_eq!(view.current_option(), ApprovalOption::Abort);
3504 let action = view.handle_key(create_key_event(KeyCode::Enter));
3505 assert!(matches!(
3506 action,
3507 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3508 decision: ReviewDecision::Abort,
3509 ..
3510 })
3511 ));
3512 }
3513
3514 #[test]
3515 fn destructive_unrelated_key_keeps_modal_open() {
3516 let mut view = ApprovalView::new(destructive_request());
3517
3518 let action = view.handle_key(create_key_event(KeyCode::Char('q')));
3519 assert!(matches!(action, ViewAction::None));
3520 }
3521
3522 #[test]
3523 fn destructive_a_first_press_approves_for_session() {
3524 for code in [KeyCode::Char('a'), KeyCode::Char('A')] {
3525 let mut view = ApprovalView::new(destructive_request());
3526
3527 let action = view.handle_key(create_key_event(code));
3528 assert!(
3529 matches!(
3530 action,
3531 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3532 decision: ReviewDecision::ApprovedForSession,
3533 ..
3534 })
3535 ),
3536 "expected ApprovedForSession for {code:?}"
3537 );
3538 }
3539 }
3540
3541 #[test]
3542 fn destructive_deny_commits_immediately() {
3543 // Deny commits immediately — the user is rejecting the tool.
3544 for code in [
3545 KeyCode::Char('n'),
3546 KeyCode::Char('N'),
3547 KeyCode::Char('d'),
3548 KeyCode::Char('D'),
3549 ] {
3550 let mut view = ApprovalView::new(destructive_request());
3551 let action = view.handle_key(create_key_event(code));
3552 assert!(
3553 matches!(
3554 action,
3555 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3556 decision: ReviewDecision::Denied,
3557 ..
3558 })
3559 ),
3560 "expected Denied for {code:?}"
3561 );
3562 }
3563 }
3564
3565 #[test]
3566 fn destructive_esc_aborts_immediately() {
3567 let mut view = ApprovalView::new(destructive_request());
3568 let action = view.handle_key(create_key_event(KeyCode::Esc));
3569 assert!(matches!(
3570 action,
3571 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
3572 decision: ReviewDecision::Abort,
3573 ..
3574 })
3575 ));
3576 }
3577
3578 // ========================================================================
3579 // Render approval-card smoke tests — keep the visual contract honest.
3580 // ========================================================================
3581
3582 fn render_lines(view: &ApprovalView, w: u16, h: u16) -> Vec<String> {
3583 use ratatui::buffer::Buffer;
3584 use ratatui::layout::Rect;
3585 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
3586 ModalView::render(view, Rect::new(0, 0, w, h), &mut buf);
3587 (0..buf.area.height)
3588 .map(|row| {
3589 (0..buf.area.width)
3590 .map(|col| buf[(col, row)].symbol().to_string())
3591 .collect::<String>()
3592 })
3593 .collect()
3594 }
3595
3596 fn compact_rendered_text(lines: &[String]) -> String {
3597 lines.join("\n").replace(' ', "")
3598 }
3599
3600 fn assert_approval_key_badges_visible(joined: &str) {
3601 for badge in ["[1 / y]", "[2 / a]", "[3 / d / n]", "[Esc]"] {
3602 assert!(
3603 joined.contains(badge),
3604 "missing key badge {badge}:\n{joined}"
3605 );
3606 }
3607 }
3608
3609 #[test]
3610 fn web_run_risk_is_param_aware() {
3611 // search/query is benign; open/click fetch arbitrary URLs -> destructive.
3612 assert_eq!(
3613 classify_risk("web_run", ToolCategory::Network, &json!({"search": "rust"})),
3614 RiskLevel::Benign
3615 );
3616 assert_eq!(
3617 classify_risk(
3618 "web_run",
3619 ToolCategory::Network,
3620 &json!({"open": [{"ref": "https://evil.example"}]})
3621 ),
3622 RiskLevel::Destructive
3623 );
3624 assert_eq!(
3625 classify_risk(
3626 "web_run",
3627 ToolCategory::Network,
3628 &json!({"click": [{"ref": "1"}]})
3629 ),
3630 RiskLevel::Destructive
3631 );
3632 }
3633
3634 #[test]
3635 fn stakes_split_routine_elevated_critical() {
3636 assert_eq!(benign_request().stakes(), ApprovalStakes::Routine);
3637 assert_eq!(destructive_request().stakes(), ApprovalStakes::Elevated);
3638 assert_eq!(shell_request().stakes(), ApprovalStakes::Elevated);
3639 assert_eq!(critical_request().stakes(), ApprovalStakes::Critical);
3640 // Publish-like shell is critical in every origin.
3641 let publish = ApprovalRequest::new(
3642 "test-id",
3643 "exec_shell",
3644 "Run a shell command",
3645 &json!({"command": "git push origin main"}),
3646 "tool:exec_shell",
3647 );
3648 assert_eq!(publish.stakes(), ApprovalStakes::Critical);
3649 }
3650
3651 #[test]
3652 fn agent_tool_is_classified_and_renders_calm() {
3653 assert_eq!(get_tool_category("agent"), ToolCategory::Agent);
3654
3655 let request = ApprovalRequest::new(
3656 "test-id",
3657 "agent",
3658 "Start a sub-agent",
3659 &json!({"action": "start", "type": "explore", "prompt": "map the workspace"}),
3660 "tool:agent",
3661 );
3662 assert_eq!(request.category, ToolCategory::Agent);
3663 assert_eq!(request.stakes(), ApprovalStakes::Elevated);
3664
3665 let view = ApprovalView::new(request);
3666 let lines = render_lines(&view, 100, 40);
3667 let joined = lines.join("\n");
3668 assert!(joined.contains("APPROVAL"), "{joined}");
3669 assert!(!joined.contains("DESTRUCTIVE"), "{joined}");
3670 assert!(
3671 !joined.contains("not classified"),
3672 "agent must not render the unknown-tool warning:\n{joined}"
3673 );
3674 assert!(joined.contains("Action"), "{joined}");
3675 assert!(joined.contains("start"), "{joined}");
3676 assert!(joined.contains("explore"), "{joined}");
3677 assert!(joined.contains("map the workspace"), "{joined}");
3678 }
3679
3680 #[test]
3681 fn agent_status_and_peek_are_benign() {
3682 for action in ["status", "peek", "list"] {
3683 let request = ApprovalRequest::new(
3684 "test-id",
3685 "agent",
3686 "Inspect a sub-agent",
3687 &json!({"action": action, "agent_id": "agent_1"}),
3688 "tool:agent",
3689 );
3690 assert_eq!(request.risk, RiskLevel::Benign, "{action}");
3691 assert_eq!(request.stakes(), ApprovalStakes::Routine, "{action}");
3692 }
3693 }
3694
3695 #[test]
3696 fn render_benign_includes_review_badge_and_selection_hint() {
3697 let view = ApprovalView::new(benign_request());
3698 let lines = render_lines(&view, 100, 40);
3699 let joined = lines.join("\n");
3700 assert!(joined.contains("REVIEW"), "missing REVIEW badge:\n{joined}");
3701 assert_approval_key_badges_visible(&joined);
3702 // The selection prose moved into the per-option key badges; the footer
3703 // keeps only the escape-hatch hints.
3704 assert!(
3705 joined.contains("Pg↑/↓ review"),
3706 "footer controls hint missing:\n{joined}"
3707 );
3708 assert!(joined.contains("read_file"));
3709 }
3710
3711 #[test]
3712 fn approval_footer_hints_use_muted_contrast_tier() {
3713 // #3380: the footer key hints ("Pg↑/↓ review · Alt+V/⌥V details · Esc abort")
3714 // must render one contrast tier above TEXT_HINT — TEXT_MUTED, the same
3715 // color the app-wide ActionHint modal footers use for labels.
3716 use crate::palette;
3717 use ratatui::buffer::Buffer;
3718 use ratatui::layout::Rect;
3719
3720 let view = ApprovalView::new(benign_request());
3721 let (w, h) = (100u16, 40u16);
3722 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
3723 ModalView::render(&view, Rect::new(0, 0, w, h), &mut buf);
3724
3725 let target: Vec<String> = "Pg↑/↓ review".chars().map(|c| c.to_string()).collect();
3726 let mut found = None;
3727 for y in 0..h {
3728 let symbols: Vec<String> = (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect();
3729 for x in 0..=(w as usize - target.len()) {
3730 if symbols[x..x + target.len()] == target[..] {
3731 found = Some((u16::try_from(x).expect("column fits"), y));
3732 }
3733 }
3734 }
3735 let (x, y) = found.expect("footer key hints must be rendered");
3736 assert_eq!(
3737 buf[(x, y)].fg,
3738 palette::TEXT_MUTED,
3739 "footer key hints must use the muted (not hint) contrast tier"
3740 );
3741 }
3742
3743 #[test]
3744 fn render_elevated_write_is_calm_and_compact() {
3745 // Ordinary state-touching work (a file write) renders as a calm
3746 // APPROVAL ask: no DESTRUCTIVE badge, no policy dossier, no
3747 // impact/category taxonomy — that detail stays one details chord away.
3748 let view = ApprovalView::new(destructive_request());
3749 let lines = render_lines(&view, 100, 40);
3750 let joined = lines.join("\n");
3751 assert!(joined.contains("APPROVAL"), "missing calm badge:\n{joined}");
3752 assert!(
3753 !joined.contains("DESTRUCTIVE"),
3754 "routine write must not scream DESTRUCTIVE:\n{joined}"
3755 );
3756 assert_approval_key_badges_visible(&joined);
3757 assert!(
3758 joined.contains("Pg↑/↓ review"),
3759 "footer controls hint missing:\n{joined}"
3760 );
3761 assert!(
3762 !joined.contains("active approval policy"),
3763 "policy prose is critical-only:\n{joined}"
3764 );
3765 assert!(
3766 !joined.contains("Impact:"),
3767 "impact dossier is critical-only:\n{joined}"
3768 );
3769 assert!(
3770 !joined.contains("Type:"),
3771 "category taxonomy is critical-only:\n{joined}"
3772 );
3773 assert!(joined.contains("write_file"));
3774 }
3775
3776 #[test]
3777 fn render_critical_shows_warning_badge_and_policy_semantics() {
3778 // Genuinely destructive work keeps the strong styling and the
3779 // policy/cancel semantics.
3780 let view = ApprovalView::new(critical_request());
3781 let lines = render_lines(&view, 100, 40);
3782 let joined = lines.join("\n");
3783 assert!(
3784 joined.contains("DESTRUCTIVE"),
3785 "missing DESTRUCTIVE badge:\n{joined}"
3786 );
3787 assert_approval_key_badges_visible(&joined);
3788 assert!(
3789 joined.contains("active approval policy"),
3790 "missing policy/review-rule semantics:\n{joined}"
3791 );
3792 assert!(
3793 joined.contains("Deny rejects only this tool call"),
3794 "missing deny-vs-abort semantics:\n{joined}"
3795 );
3796 assert!(joined.contains("rm -rf"));
3797 }
3798
3799 #[test]
3800 fn render_elevated_zh_hans_is_calm_and_localized() {
3801 let view = ApprovalView::new_for_locale(destructive_request(), Locale::ZhHans);
3802 let lines = render_lines(&view, 100, 40);
3803 let joined = compact_rendered_text(&lines);
3804 assert!(
3805 joined.contains("需要批准"),
3806 "missing zh calm badge:\n{joined}"
3807 );
3808 assert!(
3809 !joined.contains("破坏性"),
3810 "routine write must not use the destructive zh badge:\n{joined}"
3811 );
3812 assert!(
3813 joined.contains("Pg↑/↓回看"),
3814 "missing zh footer controls hint:\n{joined}"
3815 );
3816 assert!(
3817 !joined.contains("影响:"),
3818 "impact dossier is critical-only:\n{joined}"
3819 );
3820 assert!(
3821 joined.contains("仅允许本次"),
3822 "missing zh approve option:\n{joined}"
3823 );
3824 }
3825
3826 #[test]
3827 fn approval_review_and_save_hints_stay_on_one_row_at_80_columns() {
3828 for &locale in Locale::shipped() {
3829 let view = ApprovalView::new_for_locale(destructive_request(), locale);
3830 let lines = render_lines(&view, 80, 40);
3831 let review_rows = lines
3832 .iter()
3833 .filter(|line| line.contains("Pg↑/↓"))
3834 .collect::<Vec<_>>();
3835
3836 assert_eq!(
3837 review_rows.len(),
3838 1,
3839 "expected one approval review-hint row for {locale:?}:\n{}",
3840 lines.join("\n")
3841 );
3842 let controls = review_rows[0];
3843 assert!(
3844 controls.contains("Esc") && controls.contains(" s "),
3845 "review, abort, and save-rule hints wrapped for {locale:?}:\n{}",
3846 lines.join("\n")
3847 );
3848 }
3849 }
3850
3851 #[test]
3852 fn render_critical_zh_hans_localizes_security_copy() {
3853 let view = ApprovalView::new_for_locale(critical_request(), Locale::ZhHans);
3854 let lines = render_lines(&view, 100, 40);
3855 let joined = compact_rendered_text(&lines);
3856 assert!(
3857 joined.contains("破坏性"),
3858 "missing zh risk badge:\n{joined}"
3859 );
3860 assert!(
3861 joined.contains("影响:"),
3862 "missing zh impact label:\n{joined}"
3863 );
3864 assert!(
3865 joined.contains("规则:"),
3866 "missing zh policy semantics:\n{joined}"
3867 );
3868 assert!(
3869 joined.contains("仅允许本次"),
3870 "missing zh approve option:\n{joined}"
3871 );
3872 }
3873
3874 #[test]
3875 fn render_takeover_card_fills_most_of_area() {
3876 // The card should be wider than the old 65-cell popup whenever
3877 // the terminal can hold it; this guards against a regression
3878 // back to the centered popup.
3879 let view = ApprovalView::new(benign_request());
3880 let lines = render_lines(&view, 120, 40);
3881 // Find the widest non-blank rendered row.
3882 let widest = lines
3883 .iter()
3884 .map(|l| l.trim_end_matches(' ').len())
3885 .max()
3886 .unwrap_or(0);
3887 assert!(
3888 widest >= 80,
3889 "takeover card too narrow: widest row = {widest} cells"
3890 );
3891 }
3892
3893 // ========================================================================
3894 // ElevationView Tests
3895 // ========================================================================
3896
3897 #[test]
3898 fn test_elevation_view_initial_state() {
3899 let request =
3900 ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false);
3901 let view = ElevationView::new(request, Locale::En);
3902 assert_eq!(view.selected, 0);
3903 }
3904
3905 #[test]
3906 fn test_elevation_view_keybindings() {
3907 let request =
3908 ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true);
3909 let mut view = ElevationView::new(request, Locale::En);
3910
3911 let action = view.handle_key(create_key_event(KeyCode::Char('n')));
3912 assert!(matches!(
3913 action,
3914 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
3915 option: ElevationOption::WithNetwork,
3916 ..
3917 })
3918 ));
3919
3920 let request =
3921 ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true);
3922 let mut view = ElevationView::new(request, Locale::En);
3923 let action = view.handle_key(create_key_event(KeyCode::Char('w')));
3924 assert!(matches!(
3925 action,
3926 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
3927 option: ElevationOption::WithWriteAccess(_),
3928 ..
3929 })
3930 ));
3931
3932 let request =
3933 ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
3934 let mut view = ElevationView::new(request, Locale::En);
3935 let action = view.handle_key(create_key_event(KeyCode::Char('f')));
3936 assert!(matches!(
3937 action,
3938 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
3939 option: ElevationOption::FullAccess,
3940 ..
3941 })
3942 ));
3943
3944 let request =
3945 ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
3946 let mut view = ElevationView::new(request, Locale::En);
3947 let action = view.handle_key(create_key_event(KeyCode::Esc));
3948 assert!(matches!(
3949 action,
3950 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
3951 option: ElevationOption::Abort,
3952 ..
3953 })
3954 ));
3955
3956 let request =
3957 ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
3958 let mut view = ElevationView::new(request, Locale::En);
3959 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
3960 assert!(matches!(
3961 action,
3962 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
3963 option: ElevationOption::Abort,
3964 ..
3965 })
3966 ));
3967 }
3968
3969 #[test]
3970 fn test_elevation_view_navigation() {
3971 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
3972 let mut view = ElevationView::new(request, Locale::En);
3973
3974 assert_eq!(view.selected, 0);
3975
3976 view.handle_key(create_key_event(KeyCode::Down));
3977 assert_eq!(view.selected, 1);
3978
3979 view.handle_key(create_key_event(KeyCode::Up));
3980 assert_eq!(view.selected, 0);
3981
3982 view.handle_key(create_key_event(KeyCode::Char('j')));
3983 assert_eq!(view.selected, 1);
3984
3985 view.handle_key(create_key_event(KeyCode::Char('k')));
3986 assert_eq!(view.selected, 0);
3987 }
3988
3989 #[test]
3990 fn test_elevation_view_enter_uses_selected_option() {
3991 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
3992 let mut view = ElevationView::new(request, Locale::En);
3993
3994 view.handle_key(create_key_event(KeyCode::Down));
3995 assert_eq!(view.selected, 1);
3996
3997 let action = view.handle_key(create_key_event(KeyCode::Enter));
3998 assert!(matches!(
3999 action,
4000 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
4001 option: ElevationOption::FullAccess,
4002 ..
4003 })
4004 ));
4005 }
4006
4007 fn render_elevation_lines(view: &ElevationView, w: u16, h: u16) -> Vec<String> {
4008 use ratatui::buffer::Buffer;
4009 use ratatui::layout::Rect;
4010 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
4011 view.render(Rect::new(0, 0, w, h), &mut buf);
4012 (0..h)
4013 .map(|row| {
4014 (0..w)
4015 .map(|col| buf[(col, row)].symbol().to_string())
4016 .collect::<String>()
4017 })
4018 .collect()
4019 }
4020
4021 fn compact_elevation_text(lines: &[String]) -> String {
4022 lines.join("\n").replace(' ', "")
4023 }
4024
4025 fn elevation_shell_request() -> ElevationRequest {
4026 ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false)
4027 }
4028
4029 #[test]
4030 fn test_elevation_render_en_has_expected_strings() {
4031 let view = ElevationView::new(elevation_shell_request(), Locale::En);
4032 let lines = render_elevation_lines(&view, 70, 22);
4033 let joined = compact_elevation_text(&lines);
4034 assert!(
4035 joined.contains("SandboxDenied"),
4036 "missing en title:\n{joined}"
4037 );
4038 assert!(joined.contains("Tool:"), "missing en tool label:\n{joined}");
4039 assert!(joined.contains("Cmd:"), "missing en cmd label:\n{joined}");
4040 assert!(
4041 joined.contains("Reason:"),
4042 "missing en reason label:\n{joined}"
4043 );
4044 }
4045
4046 #[test]
4047 fn test_elevation_render_zh_hans_localizes_copy() {
4048 let view = ElevationView::new(elevation_shell_request(), Locale::ZhHans);
4049 let lines = render_elevation_lines(&view, 70, 22);
4050 let joined = compact_elevation_text(&lines);
4051 assert!(joined.contains("沙箱拒绝"), "missing zh title:\n{joined}");
4052 assert!(
4053 joined.contains("工具:"),
4054 "missing zh tool label:\n{joined}"
4055 );
4056 assert!(joined.contains("命令:"), "missing zh cmd label:\n{joined}");
4057 assert!(
4058 joined.contains("原因:"),
4059 "missing zh reason label:\n{joined}"
4060 );
4061 assert!(
4062 joined.contains("批准后的影响"),
4063 "missing zh impact header:\n{joined}"
4064 );
4065 let en_artifacts = [
4066 "SandboxDenied",
4067 "Tool:",
4068 "Cmd:",
4069 "Reason:",
4070 "Impactifapproved",
4071 "Choosehowtoproceed",
4072 "Allowoutboundnetwork",
4073 "Allowextrawriteaccess",
4074 "Fullaccess",
4075 "Abort",
4076 ];
4077 for artifact in &en_artifacts {
4078 assert!(
4079 !joined.contains(artifact),
4080 "English leak '{artifact}' in zh rendering:\n{joined}"
4081 );
4082 }
4083 }
4084
4085 #[test]
4086 fn test_elevation_render_ja_has_translated_copy() {
4087 let view = ElevationView::new(elevation_shell_request(), Locale::Ja);
4088 let lines = render_elevation_lines(&view, 70, 22);
4089 let joined = compact_elevation_text(&lines);
4090 assert!(
4091 joined.contains("サンドボックス拒否"),
4092 "missing ja title:\n{joined}"
4093 );
4094 assert!(
4095 joined.contains("ツール:"),
4096 "missing ja tool label:\n{joined}"
4097 );
4098 assert!(
4099 joined.contains("コマンド:"),
4100 "missing ja cmd label:\n{joined}"
4101 );
4102 assert!(
4103 joined.contains("理由:"),
4104 "missing ja reason label:\n{joined}"
4105 );
4106 for eng in &["SandboxDenied", "Tool:", "Cmd:", "Reason:"] as &[&str] {
4107 assert!(
4108 !joined.contains(eng),
4109 "English leak '{eng}' in ja:\n{joined}"
4110 );
4111 }
4112 }
4113
4114 #[test]
4115 fn test_elevation_render_zh_hant_has_translated_copy() {
4116 let view = ElevationView::new(elevation_shell_request(), Locale::ZhHant);
4117 let lines = render_elevation_lines(&view, 70, 22);
4118 let joined = compact_elevation_text(&lines);
4119 assert!(
4120 joined.contains("沙箱拒絕"),
4121 "missing zh-Hant title:\n{joined}"
4122 );
4123 assert!(
4124 joined.contains("工具:"),
4125 "missing zh-Hant tool label:\n{joined}"
4126 );
4127 assert!(
4128 joined.contains("命令:"),
4129 "missing zh-Hant cmd label:\n{joined}"
4130 );
4131 assert!(
4132 joined.contains("原因:"),
4133 "missing zh-Hant reason label:\n{joined}"
4134 );
4135 }
4136
4137 // ========================================================================
4138 // ElevationOption Tests
4139 // ========================================================================
4140
4141 #[test]
4142 fn test_elevation_option_labels() {
4143 assert_eq!(
4144 ElevationOption::WithNetwork.label(),
4145 "Allow outbound network"
4146 );
4147 assert_eq!(
4148 ElevationOption::FullAccess.label(),
4149 "Full access (filesystem + network)"
4150 );
4151 assert!(
4152 ElevationOption::WithWriteAccess(vec![])
4153 .label()
4154 .contains("write")
4155 );
4156 assert_eq!(ElevationOption::Abort.label(), "Abort");
4157 }
4158
4159 #[test]
4160 fn test_elevation_option_descriptions() {
4161 assert!(
4162 ElevationOption::WithNetwork
4163 .description()
4164 .contains("network")
4165 );
4166 assert!(
4167 ElevationOption::FullAccess
4168 .description()
4169 .contains("filesystem and network access")
4170 );
4171 assert!(ElevationOption::Abort.description().contains("Cancel"));
4172 }
4173
4174 #[test]
4175 fn test_elevation_option_to_policy() {
4176 let cwd = PathBuf::from("/tmp/test");
4177
4178 let policy = ElevationOption::WithNetwork.to_policy(&cwd);
4179 assert!(matches!(
4180 policy,
4181 SandboxPolicy::WorkspaceWrite {
4182 network_access: true,
4183 ..
4184 }
4185 ));
4186
4187 let policy = ElevationOption::FullAccess.to_policy(&cwd);
4188 assert!(matches!(policy, SandboxPolicy::DangerFullAccess));
4189
4190 let paths = vec![PathBuf::from("/tmp/test/src")];
4191 let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd);
4192 assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
4193 }
4194
4195 // ========================================================================
4196 // ElevationRequest Tests
4197 // ========================================================================
4198
4199 #[test]
4200 fn test_elevation_request_for_shell_with_network_block() {
4201 let request = ElevationRequest::for_shell(
4202 "test-id",
4203 "curl example.com",
4204 "network blocked",
4205 true,
4206 false,
4207 );
4208
4209 assert_eq!(request.tool_id, "test-id");
4210 assert_eq!(request.tool_name, "exec_shell");
4211 assert!(request.command.is_some());
4212 assert!(request.denial_reason.contains("network"));
4213 assert!(
4214 request
4215 .options
4216 .iter()
4217 .any(|o| matches!(o, ElevationOption::WithNetwork))
4218 );
4219 }
4220
4221 #[test]
4222 fn test_elevation_request_for_shell_with_write_block() {
4223 let request =
4224 ElevationRequest::for_shell("test-id", "rm -rf /tmp", "write blocked", false, true);
4225
4226 assert_eq!(request.tool_id, "test-id");
4227 assert!(
4228 request
4229 .options
4230 .iter()
4231 .any(|o| matches!(o, ElevationOption::WithWriteAccess(_)))
4232 );
4233 }
4234
4235 #[test]
4236 fn test_elevation_request_generic() {
4237 let request = ElevationRequest::generic("test-id", "some_tool", "permission denied");
4238
4239 assert_eq!(request.tool_id, "test-id");
4240 assert_eq!(request.tool_name, "some_tool");
4241 assert!(request.command.is_none());
4242 assert!(
4243 request
4244 .options
4245 .iter()
4246 .any(|o| matches!(o, ElevationOption::WithNetwork))
4247 );
4248 assert!(
4249 request
4250 .options
4251 .iter()
4252 .any(|o| matches!(o, ElevationOption::FullAccess))
4253 );
4254 assert!(
4255 request
4256 .options
4257 .iter()
4258 .any(|o| matches!(o, ElevationOption::Abort))
4259 );
4260 }
4261
4262 // ========================================================================
4263 // Workflow elevated plan approval card (#4126)
4264 // ========================================================================
4265
4266 #[test]
4267 fn workflow_tool_is_agent_category_and_shows_plan_card_fields() {
4268 assert_eq!(get_tool_category("workflow"), ToolCategory::Agent);
4269 let request = ApprovalRequest::new(
4270 "wf-1",
4271 "workflow",
4272 "Launch workflow",
4273 &json!({
4274 "action": "start",
4275 "plan": {
4276 "goal": "ship the fix",
4277 "risk": "writes",
4278 "token_budget": 80_000,
4279 "children": [
4280 {
4281 "id": "impl",
4282 "label": "builder",
4283 "prompt": "edit files",
4284 "type": "implementer",
4285 "mode": "read_write"
4286 }
4287 ]
4288 }
4289 }),
4290 "tool:workflow",
4291 );
4292 assert_eq!(request.category, ToolCategory::Agent);
4293 let details = request.prominent_detail_items(Locale::En);
4294 let labels: Vec<_> = details.iter().map(|d| d.label.as_str()).collect();
4295 assert!(labels.contains(&"Goal"), "{labels:?}");
4296 assert!(labels.contains(&"Children"), "{labels:?}");
4297 assert!(labels.contains(&"Writes"), "{labels:?}");
4298 assert!(labels.contains(&"Shell"), "{labels:?}");
4299 assert!(labels.contains(&"Network"), "{labels:?}");
4300 assert!(labels.contains(&"Budget"), "{labels:?}");
4301 assert!(
4302 details
4303 .iter()
4304 .any(|d| d.label == "Goal" && d.value.contains("ship the fix")),
4305 "{details:?}"
4306 );
4307 assert!(
4308 details
4309 .iter()
4310 .any(|d| d.label == "Writes" && d.value == "yes"),
4311 "{details:?}"
4312 );
4313 assert!(
4314 request
4315 .impacts
4316 .iter()
4317 .any(|i| i.contains("Approve to launch")),
4318 "{:?}",
4319 request.impacts
4320 );
4321
4322 let view = ApprovalView::new(request);
4323 assert!(view.is_workflow_plan_approval());
4324 assert_eq!(view.current_option(), ApprovalOption::Deny);
4325 assert_eq!(view.current_decision(), ReviewDecision::Denied);
4326 }
4327
4328 #[test]
4329 fn workflow_plan_card_edit_plan_and_cancel_keys() {
4330 let request = ApprovalRequest::new(
4331 "wf-2",
4332 "workflow",
4333 "Launch workflow",
4334 &json!({
4335 "action": "start",
4336 "plan": {
4337 "goal": "risky",
4338 "risk": "elevated",
4339 "children": [{ "prompt": "go", "type": "implementer" }]
4340 }
4341 }),
4342 "tool:workflow",
4343 );
4344 let mut view = ApprovalView::new(request);
4345 // [2 / e] → Edit plan → Denied
4346 let action = view.handle_key(create_key_event(KeyCode::Char('e')));
4347 match action {
4348 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { decision, .. }) => {
4349 assert_eq!(decision, ReviewDecision::Denied);
4350 }
4351 other => panic!("expected edit-plan denial, got {other:?}"),
4352 }
4353
4354 let request = ApprovalRequest::new(
4355 "wf-3",
4356 "workflow",
4357 "Launch workflow",
4358 &json!({
4359 "action": "start",
4360 "plan": {
4361 "goal": "risky",
4362 "risk": "elevated",
4363 "children": [{ "prompt": "go", "type": "implementer" }]
4364 }
4365 }),
4366 "tool:workflow",
4367 );
4368 let mut view = ApprovalView::new(request);
4369 let action = view.handle_key(create_key_event(KeyCode::Char('3')));
4370 match action {
4371 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { decision, .. }) => {
4372 assert_eq!(decision, ReviewDecision::Abort);
4373 }
4374 other => panic!("expected cancel abort, got {other:?}"),
4375 }
4376 }
4377
4378 // ========================================================================
4379 // ApprovalMode Tests
4380 // ========================================================================
4381
4382 #[test]
4383 fn test_approval_mode_labels() {
4384 assert_eq!(ApprovalMode::Auto.label(), "AUTO");
4385 assert_eq!(ApprovalMode::Suggest.label(), "SUGGEST");
4386 assert_eq!(ApprovalMode::Never.label(), "NEVER");
4387 }
4388
4389 #[test]
4390 fn test_approval_mode_from_config_value_accepts_aliases() {
4391 assert_eq!(
4392 ApprovalMode::from_config_value("auto"),
4393 Some(ApprovalMode::Auto)
4394 );
4395 assert_eq!(
4396 ApprovalMode::from_config_value("on-request"),
4397 Some(ApprovalMode::Suggest)
4398 );
4399 assert_eq!(
4400 ApprovalMode::from_config_value("full_access"),
4401 Some(ApprovalMode::Bypass)
4402 );
4403 assert_eq!(
4404 ApprovalMode::from_config_value("deny"),
4405 Some(ApprovalMode::Never)
4406 );
4407 assert_eq!(ApprovalMode::from_config_value("unknown"), None);
4408 }
4409
4410 #[test]
4411 fn canonical_bash_keeps_original_name_but_uses_shell_approval_semantics() {
4412 let request = ApprovalRequest::new_with_intent(
4413 "bash-1",
4414 "Bash",
4415 "Run command",
4416 &json!({"action": "run", "command": "cargo test", "cwd": "/workspace"}),
4417 "tool:Bash",
4418 None,
4419 Path::new("/workspace"),
4420 );
4421
4422 assert_eq!(request.tool_name, "Bash");
4423 assert_eq!(request.category, ToolCategory::Shell);
4424 assert_eq!(request.risk, RiskLevel::Destructive);
4425 assert_eq!(
4426 request.persistent_ask_rules,
4427 vec![ToolAskRule::exec_shell("cargo test")]
4428 );
4429 let details = request.prominent_detail_items(Locale::En);
4430 assert!(
4431 details
4432 .iter()
4433 .any(|detail| detail.label == "Command" && detail.value == "cargo test")
4434 );
4435 }
4436
4437 #[test]
4438 fn canonical_file_mutations_get_legacy_previews_and_scoped_ask_rules() {
4439 let cases = [
4440 (
4441 "write",
4442 json!({
4443 "action": "write",
4444 "path": "/workspace/src/lib.rs",
4445 "content": "pub fn whale() {}\n"
4446 }),
4447 "write_file",
4448 "+ pub fn whale() {}",
4449 ),
4450 (
4451 "edit",
4452 json!({
4453 "action": "edit",
4454 "path": "/workspace/src/lib.rs",
4455 "search": "old",
4456 "replace": "new"
4457 }),
4458 "edit_file",
4459 "- old",
4460 ),
4461 (
4462 "patch",
4463 json!({
4464 "action": "patch",
4465 "patch": "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n"
4466 }),
4467 "apply_patch",
4468 "-old",
4469 ),
4470 ];
4471
4472 for (action, params, rule_tool, preview_fragment) in cases {
4473 let request = ApprovalRequest::new_with_intent(
4474 action,
4475 "File",
4476 "Mutate file",
4477 &params,
4478 "tool:File",
4479 None,
4480 Path::new("/workspace"),
4481 );
4482 assert_eq!(request.tool_name, "File", "{action}");
4483 assert_eq!(request.category, ToolCategory::FileWrite, "{action}");
4484 assert_eq!(request.risk, RiskLevel::Destructive, "{action}");
4485 assert!(
4486 request
4487 .persistent_ask_rules
4488 .iter()
4489 .any(|rule| rule.tool == rule_tool),
4490 "{action}: {:?}",
4491 request.persistent_ask_rules
4492 );
4493 let preview = request
4494 .prominent_detail_items(Locale::En)
4495 .into_iter()
4496 .find(|detail| detail.label == "Preview")
4497 .expect("canonical file mutation must show a preview");
4498 assert!(
4499 preview.value.contains(preview_fragment),
4500 "{action}: {preview:?}"
4501 );
4502 }
4503 }
4504 }
4505
4505 lines RUST