返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / approval / tests.rs
1 use super::policy::get_tool_category;
2 use super::*;
3 use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
4 use ratatui::{Terminal, backend::TestBackend};
5 use serde_json::json;
6
7 fn create_key_event(code: KeyCode) -> KeyEvent {
8 KeyEvent {
9 code,
10 modifiers: KeyModifiers::empty(),
11 kind: crossterm::event::KeyEventKind::Press,
12 state: crossterm::event::KeyEventState::NONE,
13 }
14 }
15
16 fn benign_request() -> ApprovalRequest {
17 ApprovalRequest::new(
18 "test-id",
19 "read_file",
20 "Read a file from disk",
21 &json!({"path": "src/main.rs"}),
22 "tool:read_file",
23 )
24 }
25
26 fn destructive_request() -> ApprovalRequest {
27 ApprovalRequest::new(
28 "test-id",
29 "write_file",
30 "Write a file to disk",
31 &json!({"path": "src/main.rs", "content": "test"}),
32 "tool:write_file",
33 )
34 }
35
36 fn critical_request() -> ApprovalRequest {
37 ApprovalRequest::new(
38 "test-id",
39 "exec_shell",
40 "Run a shell command",
41 &json!({"command": "rm -rf ~/"}),
42 "tool:exec_shell",
43 )
44 }
45
46 fn shell_request() -> ApprovalRequest {
47 ApprovalRequest::new(
48 "test-id",
49 "exec_shell",
50 "Run a shell command",
51 &json!({"command": "cargo test --workspace"}),
52 "tool:exec_shell",
53 )
54 }
55
56 // ========================================================================
57 // Tool Category Tests
58 // ========================================================================
59
60 #[test]
61 fn test_get_tool_category_safe_tools() {
62 assert_eq!(get_tool_category("read_file"), ToolCategory::Safe);
63 assert_eq!(get_tool_category("list_dir"), ToolCategory::Safe);
64 assert_eq!(get_tool_category("todo_write"), ToolCategory::Safe);
65 assert_eq!(get_tool_category("work_update"), ToolCategory::Safe);
66 assert_eq!(get_tool_category("checklist_write"), ToolCategory::Safe);
67 assert_eq!(get_tool_category("todo_read"), ToolCategory::Safe);
68 assert_eq!(get_tool_category("note"), ToolCategory::Safe);
69 assert_eq!(get_tool_category("update_plan"), ToolCategory::Safe);
70 }
71
72 #[test]
73 fn test_get_tool_category_file_write_tools() {
74 assert_eq!(get_tool_category("write_file"), ToolCategory::FileWrite);
75 assert_eq!(get_tool_category("edit_file"), ToolCategory::FileWrite);
76 assert_eq!(get_tool_category("apply_patch"), ToolCategory::FileWrite);
77 }
78
79 #[test]
80 fn test_get_tool_category_shell_tools() {
81 assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell);
82 assert_eq!(get_tool_category("task_shell_start"), ToolCategory::Shell);
83 assert_eq!(get_tool_category("task_shell_wait"), ToolCategory::Shell);
84 assert_eq!(get_tool_category("exec_shell_wait"), ToolCategory::Shell);
85 assert_eq!(
86 get_tool_category("exec_shell_interact"),
87 ToolCategory::Shell
88 );
89 assert_eq!(get_tool_category("exec_wait"), ToolCategory::Shell);
90 assert_eq!(get_tool_category("exec_interact"), ToolCategory::Shell);
91 assert_eq!(
92 get_tool_category("mcp_linear_save_issue"),
93 ToolCategory::McpAction
94 );
95 assert_eq!(
96 get_tool_category("start_registry_mcp_server"),
97 ToolCategory::McpAction
98 );
99 assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::McpRead);
100 }
101
102 #[test]
103 fn test_get_tool_category_unknown_tools_need_review() {
104 assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Unknown);
105 }
106
107 // ========================================================================
108 // Risk Routing Tests (#129)
109 // ========================================================================
110
111 #[test]
112 fn risk_safe_categories_route_benign() {
113 let cat = ToolCategory::Safe;
114 assert_eq!(
115 classify_risk("read_file", cat, &json!({"path": "x"})),
116 RiskLevel::Benign
117 );
118 let cat = ToolCategory::McpRead;
119 assert_eq!(
120 classify_risk("list_mcp_tools", cat, &json!({})),
121 RiskLevel::Benign
122 );
123 }
124
125 #[test]
126 fn risk_query_only_network_is_benign_but_fetch_is_destructive() {
127 // web_search is read-only enough to use the benign variant.
128 let cat = ToolCategory::Network;
129 assert_eq!(
130 classify_risk("web_search", cat, &json!({"q": "rust"})),
131 RiskLevel::Benign
132 );
133 // Registry discovery mirrors web_search: query-only network → Benign.
134 assert_eq!(
135 classify_risk("registry_sync", cat, &json!({})),
136 RiskLevel::Benign
137 );
138 // fetch_url pulls arbitrary remote content, so it stays destructive.
139 assert_eq!(
140 classify_risk("fetch_url", cat, &json!({"url": "https://example.com"})),
141 RiskLevel::Destructive
142 );
143 // wait_for_dev_server only permits loopback targets.
144 assert_eq!(
145 classify_risk("wait_for_dev_server", cat, &json!({"port": 5173})),
146 RiskLevel::Benign
147 );
148 }
149
150 #[test]
151 fn risk_writes_shell_mcp_action_unknown_route_destructive() {
152 for (name, cat) in [
153 ("write_file", ToolCategory::FileWrite),
154 ("edit_file", ToolCategory::FileWrite),
155 ("apply_patch", ToolCategory::FileWrite),
156 ("exec_shell", ToolCategory::Shell),
157 ("mcp_linear_save_issue", ToolCategory::McpAction),
158 ("start_registry_mcp_server", ToolCategory::McpAction),
159 ("totally_new_tool", ToolCategory::Unknown),
160 ] {
161 assert_eq!(
162 classify_risk(name, cat, &json!({})),
163 RiskLevel::Destructive,
164 "expected {name:?} to be Destructive",
165 );
166 }
167 }
168
169 #[test]
170 fn risk_read_only_shell_commands_route_benign() {
171 let cat = ToolCategory::Shell;
172 for command in [
173 "codewhale --version",
174 "codewhale --help",
175 "git status --porcelain",
176 ] {
177 assert_eq!(
178 classify_risk("exec_shell", cat, &json!({ "command": command })),
179 RiskLevel::Benign,
180 "expected read-only shell command {command:?} to be Benign",
181 );
182 }
183 }
184
185 #[test]
186 fn risk_dangerous_shell_command_stays_destructive() {
187 // command_safety would flag this as Dangerous; classify_risk
188 // already routes Shell to Destructive. The check exists so a
189 // future attempt to relax shell to Benign cannot smuggle this
190 // through unexamined.
191 let cat = ToolCategory::Shell;
192 assert_eq!(
193 classify_risk("exec_shell", cat, &json!({"command": "rm -rf /"})),
194 RiskLevel::Destructive
195 );
196 }
197
198 // ========================================================================
199 // ApprovalRequest Tests
200 // ========================================================================
201
202 #[test]
203 fn test_approval_request_new() {
204 let params = json!({"path": "src/main.rs", "content": "test"});
205 let request = ApprovalRequest::new(
206 "test-id",
207 "write_file",
208 "Write a file to disk",
209 &params,
210 "test_key",
211 );
212
213 assert_eq!(request.id, "test-id");
214 assert_eq!(request.tool_name, "write_file");
215 assert_eq!(request.category, ToolCategory::FileWrite);
216 assert_eq!(request.risk, RiskLevel::Destructive);
217 assert_eq!(request.params, params);
218 }
219
220 #[test]
221 fn test_approval_request_params_display_truncates() {
222 let long_content = "x".repeat(300);
223 let params = json!({"path": "src/main.rs", "content": long_content});
224 let request = ApprovalRequest::new(
225 "test-id",
226 "write_file",
227 "Write a file to disk",
228 &params,
229 "test_key",
230 );
231
232 let display = request.params_display();
233 assert!(display.len() < 250);
234 assert!(display.contains("src/main.rs"));
235 }
236
237 #[test]
238 fn test_approval_request_params_display_short() {
239 let params = json!({"path": "src/main.rs"});
240 let request = ApprovalRequest::new(
241 "test-id",
242 "read_file",
243 "Read a file from disk",
244 &params,
245 "test_key",
246 );
247
248 let display = request.params_display();
249 assert!(display.contains("src/main.rs"));
250 }
251
252 #[test]
253 fn test_approval_request_derives_impact_summary() {
254 let params = json!({"cmd": "cargo test", "workdir": "/tmp/project"});
255 let request = ApprovalRequest::new(
256 "test-id",
257 "exec_shell",
258 "Run a shell command",
259 &params,
260 "test_key",
261 );
262
263 assert_eq!(request.category, ToolCategory::Shell);
264 assert!(
265 request
266 .impacts
267 .iter()
268 .any(|line| line.contains("Executes a Bash command"))
269 );
270 assert!(
271 request
272 .impacts
273 .iter()
274 .all(|line| !line.contains("cargo test")),
275 "command detail should not be duplicated in the impact summary"
276 );
277 let details = request.prominent_detail_items(Locale::En);
278 assert!(
279 details
280 .iter()
281 .any(|detail| detail.label == "Command" && detail.value.contains("cargo test"))
282 );
283 }
284
285 #[test]
286 fn mcp_impact_summary_preserves_full_target_for_underscored_names() {
287 let request = ApprovalRequest::new(
288 "test-id",
289 "mcp_my_db_execute_sql",
290 "Call an MCP tool",
291 &json!({}),
292 "tool:mcp_my_db_execute_sql",
293 );
294
295 assert!(
296 request
297 .impacts
298 .iter()
299 .any(|line| line == "MCP target: my_db_execute_sql")
300 );
301 assert!(!request.impacts.iter().any(|line| line == "Server: my"));
302
303 let zh_impacts = request.impacts_for_locale(Locale::ZhHans);
304 assert!(
305 zh_impacts
306 .iter()
307 .any(|line| line == "MCP 目标:my_db_execute_sql")
308 );
309 assert!(!zh_impacts.iter().any(|line| line == "服务器:my"));
310 }
311
312 #[test]
313 fn test_prominent_details_shell_does_not_truncate_long_command() {
314 let command = format!("printf '{}\\n' > /tmp/x && cat /tmp/x", "x".repeat(300));
315 let request = ApprovalRequest::new(
316 "test-id",
317 "exec_shell",
318 "Run a shell command",
319 &json!({"command": command, "cwd": "/tmp/project"}),
320 "test_key",
321 );
322
323 let details = request.prominent_detail_items(Locale::En);
324
325 assert_eq!(details[0].label, "Command");
326 assert_eq!(details[0].value, command);
327 assert!(
328 details[0]
329 .shell_lines
330 .as_ref()
331 .is_some_and(|lines| lines.iter().any(|line| line.contains("cat /tmp/x"))),
332 "shell preview should preserve the dangerous tail of long commands"
333 );
334 assert_eq!(details[1].label, "Dir");
335 assert_eq!(details[1].value, "/tmp/project");
336 }
337
338 #[test]
339 fn test_prominent_details_file_write() {
340 let request = ApprovalRequest::new(
341 "test-id",
342 "write_file",
343 "Write a file to disk",
344 &json!({"path": "src/main.rs", "content": "fn main() {}"}),
345 "test_key",
346 );
347
348 let details = request.prominent_detail_items(Locale::En);
349
350 assert_eq!(details[0].label, "File");
351 assert_eq!(details[0].value, "src/main.rs");
352 assert!(details[0].shell_lines.is_none());
353 assert_eq!(details[1].label, "Preview");
354 let preview = details[1].shell_lines.as_ref().expect("preview lines");
355 assert!(preview.iter().any(|line| line == "+ fn main() {}"));
356 }
357
358 #[test]
359 fn prominent_details_edit_file_includes_search_replace_preview() {
360 let request = ApprovalRequest::new(
361 "test-id",
362 "edit_file",
363 "Edit a file on disk",
364 &json!({
365 "path": "src/lib.rs",
366 "search": "old_call();",
367 "replace": "new_call();"
368 }),
369 "tool:edit_file",
370 );
371
372 let details = request.prominent_detail_items(Locale::En);
373 let preview = details
374 .iter()
375 .find(|detail| detail.label == "Preview")
376 .and_then(|detail| detail.shell_lines.as_ref())
377 .expect("edit preview");
378
379 assert!(preview.iter().any(|line| line == "- old_call();"));
380 assert!(preview.iter().any(|line| line == "+ new_call();"));
381 }
382
383 #[test]
384 fn prominent_details_apply_patch_includes_diff_preview() {
385 let patch = r#"diff --git a/src/lib.rs b/src/lib.rs
386 --- a/src/lib.rs
387 +++ b/src/lib.rs
388 @@ -1,2 +1,2 @@
389 -old
390 +new
391 "#;
392 let request = ApprovalRequest::new(
393 "test-id",
394 "apply_patch",
395 "Apply a patch",
396 &json!({"patch": patch}),
397 "tool:apply_patch",
398 );
399
400 let details = request.prominent_detail_items(Locale::En);
401 let preview = details
402 .iter()
403 .find(|detail| detail.label == "Preview")
404 .and_then(|detail| detail.shell_lines.as_ref())
405 .expect("patch preview");
406
407 assert!(preview.iter().any(|line| line.starts_with("@@")));
408 assert!(preview.iter().any(|line| line == "-old"));
409 assert!(preview.iter().any(|line| line == "+new"));
410 }
411
412 #[test]
413 fn prominent_details_apply_patch_changes_array_preview_stays_bounded() {
414 let request = ApprovalRequest::new(
415 "test-id",
416 "apply_patch",
417 "Apply a patch",
418 &json!({
419 "replace": [
420 {
421 "path": "src/lib.rs",
422 "content": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight"
423 },
424 {
425 "path": "src/main.rs",
426 "content": "main"
427 },
428 {
429 "path": "src/extra.rs",
430 "content": "extra"
431 }
432 ]
433 }),
434 "tool:apply_patch",
435 );
436
437 let details = request.prominent_detail_items(Locale::En);
438 let preview = details
439 .iter()
440 .find(|detail| detail.label == "Preview")
441 .and_then(|detail| detail.shell_lines.as_ref())
442 .expect("changes preview");
443
444 assert!(
445 preview.len() <= 7,
446 "preview should stay bounded: {preview:?}"
447 );
448 assert!(preview.iter().any(|line| line == "file: src/lib.rs"));
449 assert_eq!(
450 preview.last().map(String::as_str),
451 Some("... (+2 more files)")
452 );
453 }
454
455 #[test]
456 fn prominent_details_apply_patch_legacy_changes_includes_preview() {
457 let request = ApprovalRequest::new(
458 "test-id",
459 "apply_patch",
460 "Apply a patch",
461 &json!({
462 "changes": [{
463 "path": "src/lib.rs",
464 "content": "fn legacy() {}\n"
465 }]
466 }),
467 "tool:apply_patch",
468 );
469
470 let details = request.prominent_detail_items(Locale::En);
471 let preview = details
472 .iter()
473 .find(|detail| detail.label == "Preview")
474 .and_then(|detail| detail.shell_lines.as_ref())
475 .expect("legacy changes preview");
476
477 assert!(preview.iter().any(|line| line == "file: src/lib.rs"));
478 assert!(preview.iter().any(|line| line == "+ fn legacy() {}"));
479 }
480
481 #[test]
482 fn apply_patch_changes_array_preview_reports_second_file_when_first_fills_buffer() {
483 let request = ApprovalRequest::new(
484 "test-id",
485 "apply_patch",
486 "Apply a patch",
487 &json!({
488 "replace": [
489 {
490 "path": "src/lib.rs",
491 "content": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight"
492 },
493 {
494 "path": "src/main.rs",
495 "content": "main"
496 }
497 ]
498 }),
499 "tool:apply_patch",
500 );
501
502 let details = request.prominent_detail_items(Locale::En);
503 let preview = details
504 .iter()
505 .find(|detail| detail.label == "Preview")
506 .and_then(|detail| detail.shell_lines.as_ref())
507 .expect("changes preview");
508
509 assert!(
510 preview.len() <= 7,
511 "preview should stay bounded: {preview:?}"
512 );
513 assert!(preview.iter().any(|line| line == "file: src/lib.rs"));
514 assert_eq!(
515 preview.last().map(String::as_str),
516 Some("... (+1 more files)")
517 );
518 }
519
520 #[test]
521 fn apply_patch_preview_counts_omitted_context_lines() {
522 let patch = r#"diff --git a/src/lib.rs b/src/lib.rs
523 --- a/src/lib.rs
524 +++ b/src/lib.rs
525 @@ -1,8 +1,8 @@
526 context one
527 context two
528 -old
529 +new
530 context three
531 context four
532 context five
533 "#;
534
535 let preview = apply_patch_preview_lines(patch).expect("patch preview");
536
537 assert!(
538 preview.len() <= 7,
539 "preview should stay bounded: {preview:?}"
540 );
541 assert_eq!(
542 preview.last().map(String::as_str),
543 Some("... (+5 more patch lines)")
544 );
545 }
546
547 #[test]
548 fn apply_patch_preview_counts_replaced_visible_line_as_omitted() {
549 let patch = r#"diff --git a/src/lib.rs b/src/lib.rs
550 --- a/src/lib.rs
551 +++ b/src/lib.rs
552 @@ -1,4 +1,4 @@
553 -old1
554 +new1
555 -old2
556 +new2
557 context one
558 context two
559 "#;
560
561 let preview = apply_patch_preview_lines(patch).expect("patch preview");
562
563 assert_eq!(preview.len(), 7);
564 assert_eq!(
565 preview.last().map(String::as_str),
566 Some("... (+4 more patch lines)")
567 );
568 }
569
570 #[test]
571 fn preview_sublabels_are_localized_for_zh_hans() {
572 let write = ApprovalRequest::new(
573 "test-id",
574 "write_file",
575 "Write a file",
576 &json!({"path": "src/lib.rs", "content": "proposed content\nreplacement content"}),
577 "tool:write_file",
578 );
579 let write_preview = write
580 .prominent_detail_items(Locale::ZhHans)
581 .into_iter()
582 .find(|detail| detail.label == "预览")
583 .and_then(|detail| detail.shell_lines)
584 .expect("localized write preview");
585 assert!(write_preview.iter().any(|line| line == "拟写入内容"));
586 assert!(
587 write_preview
588 .iter()
589 .any(|line| line == "+ proposed content")
590 );
591 assert!(
592 write_preview
593 .iter()
594 .any(|line| line == "+ replacement content")
595 );
596
597 let edit = ApprovalRequest::new(
598 "test-id",
599 "edit_file",
600 "Edit a file",
601 &json!({
602 "path": "src/lib.rs",
603 "search": "with this",
604 "replace": "replace this"
605 }),
606 "tool:edit_file",
607 );
608 let edit_preview = edit
609 .prominent_detail_items(Locale::ZhHans)
610 .into_iter()
611 .find(|detail| detail.label == "预览")
612 .and_then(|detail| detail.shell_lines)
613 .expect("localized edit preview");
614 assert!(edit_preview.iter().any(|line| line == "替换此内容"));
615 assert!(edit_preview.iter().any(|line| line == "替换为"));
616 assert!(edit_preview.iter().any(|line| line == "- with this"));
617 assert!(edit_preview.iter().any(|line| line == "+ replace this"));
618 }
619
620 #[test]
621 fn test_shell_formatter_preserves_logical_or_operator() {
622 let lines = format_shell_command_for_approval("cargo build || echo fallback");
623
624 assert_eq!(lines, vec!["cargo build ||", "echo fallback"]);
625 }
626
627 #[test]
628 fn test_shell_formatter_detects_printf_write_file_preview() {
629 let lines = format_shell_command_for_approval("printf '%s\\n' 'hello' 'world' > src/main.rs");
630
631 assert_eq!(lines[0], "printf > src/main.rs");
632 assert!(lines.iter().any(|line| line.contains("hello")));
633 assert!(lines.iter().any(|line| line.contains("world")));
634 }
635
636 // ========================================================================
637 // ApprovalView Tests — Benign Variant (single-key approve)
638 // ========================================================================
639
640 #[test]
641 fn test_approval_view_initial_state() {
642 let view = ApprovalView::new(benign_request());
643 assert_eq!(view.current_option(), ApprovalOption::Deny);
644 assert!(view.timeout.is_none());
645 assert_eq!(view.risk(), RiskLevel::Benign);
646 }
647
648 #[test]
649 fn zero_timeout_builder_keeps_the_card_unbounded() {
650 let mut view =
651 ApprovalView::new(benign_request()).with_timeout(Some(std::time::Duration::ZERO));
652 assert!(view.timeout.is_none());
653 assert!(matches!(view.tick(), ViewAction::None));
654 }
655
656 #[test]
657 fn expired_approval_card_denies_fail_closed() {
658 let mut view =
659 ApprovalView::new(benign_request()).with_timeout(Some(std::time::Duration::from_secs(30)));
660 view.requested_at = std::time::Instant::now() - std::time::Duration::from_secs(31);
661
662 assert!(matches!(
663 view.tick(),
664 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
665 decision: ReviewDecision::Denied,
666 timed_out: true,
667 ..
668 })
669 ));
670 }
671
672 #[test]
673 fn unexpired_approval_card_stays_open() {
674 let mut view =
675 ApprovalView::new(benign_request()).with_timeout(Some(std::time::Duration::from_secs(30)));
676 assert!(matches!(view.tick(), ViewAction::None));
677 }
678
679 #[test]
680 fn exec_shell_request_builds_ask_rule_preview() {
681 let request = shell_request();
682
683 assert_eq!(
684 request.persistent_ask_rules,
685 vec![ToolAskRule::exec_shell("cargo test --workspace")]
686 );
687 let preview = request.ask_rule_preview().expect("preview");
688 assert!(preview.contains("[[rules]]"));
689 assert!(preview.contains("tool = \"exec_shell\""));
690 assert!(preview.contains("command = \"cargo test --workspace\""));
691 }
692
693 #[test]
694 fn ask_rule_save_preview_formats_shell_rule() {
695 let request = shell_request();
696
697 let preview = request.ask_rule_save_preview().expect("save preview");
698 assert_eq!(preview.rule_count, 1);
699 assert_eq!(preview.summary(), "1 ask rule");
700 assert_eq!(
701 preview.entries,
702 vec!["tool=exec_shell command=cargo test --workspace"]
703 );
704 assert_eq!(preview.omitted, 0);
705 }
706
707 #[test]
708 fn safe_shell_request_builds_exact_workspace_allow_rule() {
709 let request = shell_request();
710 let expected =
711 ToolAskRule::exec_shell("cargo test --workspace").into_exact_workspace_allow("/workspace");
712
713 assert!(request.can_save_allow_rule());
714 assert_eq!(request.persistent_allow_rules, vec![expected]);
715 let preview = request.allow_rule_save_preview().expect("allow preview");
716 assert_eq!(preview.summary(), "1 allow rule");
717 assert_eq!(
718 preview.entries,
719 vec![
720 "tool=exec_shell command=cargo test --workspace command_exact=true workspace=/workspace"
721 ]
722 );
723 }
724
725 #[test]
726 fn unsafe_shell_requests_cannot_persist_allow_rules() {
727 for command in [
728 "rm -rf ~/",
729 "git push origin main",
730 "curl https://example.com",
731 "cargo test && git status",
732 ] {
733 let request = ApprovalRequest::new(
734 "test-id",
735 "exec_shell",
736 "Run a shell command",
737 &json!({"command": command}),
738 "tool:exec_shell",
739 );
740 assert!(
741 request.persistent_allow_rules.is_empty(),
742 "{command:?} must not produce a remembered allow grant"
743 );
744 assert!(!request.can_save_allow_rule(), "{command:?}");
745 assert_eq!(request.allow_rule_save_preview(), None, "{command:?}");
746 }
747 }
748
749 #[test]
750 fn file_ask_rule_saved_for_write_file_approval() {
751 // A write_file approval offers an exact, workspace-relative file rule
752 // plus a preview so `S` can persist it.
753 let request = destructive_request();
754
755 assert_eq!(
756 request.persistent_ask_rules,
757 vec![ToolAskRule::file_path("write_file", "src/main.rs")]
758 );
759 assert!(request.can_save_ask_rule());
760 let preview = request.ask_rule_preview().expect("preview");
761 assert!(preview.contains("[[rules]]"));
762 assert!(preview.contains("tool = \"write_file\""));
763 assert!(preview.contains("path = \"src/main.rs\""));
764 }
765
766 #[test]
767 fn file_write_builds_exact_workspace_allow_rule() {
768 let request = destructive_request();
769 let expected = ToolAskRule::file_path("write_file", "src/main.rs")
770 .into_exact_workspace_allow("/workspace");
771
772 assert!(request.can_save_allow_rule());
773 assert_eq!(request.persistent_allow_rules, vec![expected]);
774 assert_eq!(
775 request
776 .allow_rule_save_preview()
777 .expect("allow preview")
778 .entries,
779 vec!["tool=write_file path=src/main.rs workspace=/workspace"]
780 );
781 }
782
783 #[test]
784 fn ask_rule_save_preview_formats_write_and_edit_file_paths() {
785 let write = destructive_request();
786 let edit = ApprovalRequest::new(
787 "test-id",
788 "edit_file",
789 "Edit a file on disk",
790 &json!({"path": "/workspace/src/lib.rs"}),
791 "tool:edit_file",
792 );
793
794 assert_eq!(
795 write
796 .ask_rule_save_preview()
797 .expect("write save preview")
798 .entries,
799 vec!["tool=write_file path=src/main.rs"]
800 );
801 assert_eq!(
802 edit.ask_rule_save_preview()
803 .expect("edit save preview")
804 .entries,
805 vec!["tool=edit_file path=src/lib.rs"]
806 );
807 }
808
809 #[test]
810 fn file_ask_rule_normalizes_absolute_edit_file_path_to_workspace_relative() {
811 // An absolute in-workspace path is stored in the workspace-relative
812 // form, matching how runtime ask-rule matching normalizes paths.
813 let request = ApprovalRequest::new(
814 "test-id",
815 "edit_file",
816 "Edit a file on disk",
817 &json!({"path": "/workspace/src/lib.rs"}),
818 "tool:edit_file",
819 );
820
821 assert_eq!(
822 request.persistent_ask_rules,
823 vec![ToolAskRule::file_path("edit_file", "src/lib.rs")]
824 );
825 }
826
827 #[test]
828 fn read_file_request_has_no_file_ask_rule() {
829 // The save boundary is write approvals only; read_file never offers a
830 // persistent rule.
831 let request = benign_request();
832
833 assert!(request.persistent_ask_rules.is_empty());
834 assert!(!request.can_save_ask_rule());
835 assert_eq!(request.ask_rule_preview(), None);
836 assert_eq!(request.ask_rule_save_preview(), None);
837 }
838
839 #[test]
840 fn file_ask_rule_skipped_for_unsafe_empty_or_external_paths() {
841 // Traversal, empty, and outside-workspace paths must not become rules,
842 // so the preview and `S` shortcut stay disabled.
843 for path in ["../escape.rs", "/etc/passwd", " ", ""] {
844 let request = ApprovalRequest::new(
845 "test-id",
846 "write_file",
847 "Write a file to disk",
848 &json!({"path": path}),
849 "tool:write_file",
850 );
851 assert!(
852 request.persistent_ask_rules.is_empty(),
853 "path {path:?} must not produce a rule"
854 );
855 assert!(!request.can_save_ask_rule());
856 assert_eq!(request.ask_rule_preview(), None);
857 assert_eq!(request.ask_rule_save_preview(), None);
858 }
859 }
860
861 #[test]
862 fn apply_patch_ask_rules_saved_for_multi_file_patch() {
863 let patch = r"diff --git a/src/a.rs b/src/a.rs
864 --- a/src/a.rs
865 +++ b/src/a.rs
866 @@ -1,1 +1,1 @@
867 -old
868 +new
869 diff --git a/src/b.rs b/src/b.rs
870 --- a/src/b.rs
871 +++ b/src/b.rs
872 @@ -1,1 +1,1 @@
873 -old
874 +new
875 ";
876
877 let request = ApprovalRequest::new(
878 "test-id",
879 "apply_patch",
880 "Apply a patch",
881 &json!({"patch": patch}),
882 "tool:apply_patch",
883 );
884
885 assert_eq!(
886 request.persistent_ask_rules,
887 vec![
888 ToolAskRule::file_path("apply_patch", "src/a.rs"),
889 ToolAskRule::file_path("apply_patch", "src/b.rs"),
890 ]
891 );
892 assert!(request.can_save_ask_rule());
893 let preview = request.ask_rule_save_preview().expect("save preview");
894 assert_eq!(preview.summary(), "2 ask rules");
895 assert_eq!(
896 preview.entries,
897 vec![
898 "tool=apply_patch path=src/a.rs",
899 "tool=apply_patch path=src/b.rs"
900 ]
901 );
902 assert_eq!(
903 request.persistent_allow_rules,
904 vec![
905 ToolAskRule::file_path("apply_patch", "src/a.rs")
906 .into_exact_workspace_allow("/workspace"),
907 ToolAskRule::file_path("apply_patch", "src/b.rs")
908 .into_exact_workspace_allow("/workspace"),
909 ]
910 );
911 }
912
913 #[test]
914 fn apply_patch_ask_rules_dedupe_targets_after_normalization() {
915 let request = ApprovalRequest::new(
916 "test-id",
917 "apply_patch",
918 "Apply a patch",
919 &json!({
920 "replace": [
921 { "path": "src/a.rs", "content": "one" },
922 { "path": "/workspace/src/a.rs", "content": "two" }
923 ]
924 }),
925 "tool:apply_patch",
926 );
927
928 assert_eq!(
929 request.persistent_ask_rules,
930 vec![ToolAskRule::file_path("apply_patch", "src/a.rs")]
931 );
932 }
933
934 #[test]
935 fn apply_patch_ask_rule_handles_timestamp_headers() {
936 let patch = "diff --git a/src/lib.rs b/src/lib.rs\n\
937 --- a/src/lib.rs\t2026-06-26 10:00:00 +0000\n\
938 +++ b/src/lib.rs\t2026-06-26 10:01:00 +0000\n\
939 @@ -1,1 +1,1 @@\n\
940 -old\n\
941 +new\n";
942
943 let request = ApprovalRequest::new(
944 "test-id",
945 "apply_patch",
946 "Apply a patch",
947 &json!({"patch": patch}),
948 "tool:apply_patch",
949 );
950
951 assert_eq!(
952 request.persistent_ask_rules,
953 vec![ToolAskRule::file_path("apply_patch", "src/lib.rs")]
954 );
955 }
956
957 #[test]
958 fn apply_patch_ask_rule_ignores_forged_headers_inside_hunk() {
959 let patch = r"--- a/src/lib.rs
960 +++ b/src/lib.rs
961 @@ -1,3 +1,3 @@
962 line1
963 --- a/forged.rs
964 +++ b/forged.rs
965 line3
966 ";
967
968 let request = ApprovalRequest::new(
969 "test-id",
970 "apply_patch",
971 "Apply a patch",
972 &json!({"path": "src/lib.rs", "patch": patch}),
973 "tool:apply_patch",
974 );
975
976 assert_eq!(
977 request.persistent_ask_rules,
978 vec![ToolAskRule::file_path("apply_patch", "src/lib.rs")]
979 );
980 }
981
982 #[test]
983 fn apply_patch_ask_rule_skipped_when_any_target_traverses_workspace() {
984 let request = ApprovalRequest::new(
985 "test-id",
986 "apply_patch",
987 "Apply a patch",
988 &json!({
989 "replace": [
990 { "path": "src/a.rs", "content": "safe" },
991 { "path": "../escape.rs", "content": "unsafe" }
992 ]
993 }),
994 "tool:apply_patch",
995 );
996
997 assert!(request.persistent_ask_rules.is_empty());
998 assert!(!request.can_save_ask_rule());
999 assert_eq!(request.ask_rule_save_preview(), None);
1000 }
1001
1002 #[test]
1003 fn apply_patch_ask_rule_skipped_on_preflight_failure() {
1004 let request = ApprovalRequest::new(
1005 "test-id",
1006 "apply_patch",
1007 "Apply a patch",
1008 &json!({"patch": "@@ -1 +1 @@\n-old\n+new\n"}),
1009 "tool:apply_patch",
1010 );
1011
1012 assert!(request.persistent_ask_rules.is_empty());
1013 assert_eq!(request.ask_rule_preview(), None);
1014 assert_eq!(request.ask_rule_save_preview(), None);
1015 }
1016
1017 #[test]
1018 fn ask_rule_save_preview_truncates_rule_list() {
1019 let rules = vec![
1020 ToolAskRule::file_path("apply_patch", "src/a.rs"),
1021 ToolAskRule::file_path("apply_patch", "src/b.rs"),
1022 ToolAskRule::file_path("apply_patch", "src/c.rs"),
1023 ToolAskRule::file_path("apply_patch", "src/d.rs"),
1024 ];
1025
1026 let preview = build_permission_rule_save_preview(&rules, 2).expect("save preview");
1027 assert_eq!(preview.rule_count, 4);
1028 assert_eq!(preview.summary(), "4 ask rules");
1029 assert_eq!(
1030 preview.entries,
1031 vec![
1032 "tool=apply_patch path=src/a.rs",
1033 "tool=apply_patch path=src/b.rs"
1034 ]
1035 );
1036 assert_eq!(preview.omitted, 2);
1037 }
1038
1039 #[test]
1040 fn tab_toggles_collapsed_card_so_transcript_stays_visible() {
1041 // Regression for PR #1455 / @tiger-dog: the approval modal once hid
1042 // the transcript, so users had to dismiss the prompt to remember what
1043 // they were approving. Tab flips between the expanded compact card
1044 // and a single-line bottom banner.
1045 let mut view = ApprovalView::new(benign_request());
1046 assert!(
1047 !view.collapsed,
1048 "modal must start expanded so first-time users notice it"
1049 );
1050
1051 let action = view.handle_key(create_key_event(KeyCode::Tab));
1052 assert!(matches!(action, ViewAction::None));
1053 assert!(view.collapsed, "first Tab collapses the card");
1054
1055 let action = view.handle_key(create_key_event(KeyCode::Tab));
1056 assert!(matches!(action, ViewAction::None));
1057 assert!(!view.collapsed, "second Tab restores the expanded card");
1058 }
1059
1060 #[test]
1061 fn test_approval_view_navigation() {
1062 let mut view = ApprovalView::new(benign_request());
1063 assert_eq!(view.current_option(), ApprovalOption::Deny);
1064
1065 view.select_next();
1066 assert_eq!(view.current_option(), ApprovalOption::Abort);
1067 view.select_next();
1068 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
1069 view.select_next();
1070 assert_eq!(view.current_option(), ApprovalOption::ApproveAlways);
1071
1072 // Continue through the semantic default rather than dead-ending (#4755).
1073 view.select_next();
1074 assert_eq!(view.current_option(), ApprovalOption::Deny);
1075
1076 // And back through the same order the other way.
1077 view.select_prev();
1078 assert_eq!(view.current_option(), ApprovalOption::ApproveAlways);
1079
1080 view.select_prev();
1081 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
1082 }
1083
1084 #[test]
1085 fn benign_y_one_step_approves() {
1086 for code in [KeyCode::Char('y'), KeyCode::Char('Y')] {
1087 let mut view = ApprovalView::new(benign_request());
1088 let action = view.handle_key(create_key_event(code));
1089 assert!(
1090 matches!(
1091 action,
1092 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1093 decision: ReviewDecision::Approved,
1094 ..
1095 })
1096 ),
1097 "expected Approved for {code:?}"
1098 );
1099 }
1100 }
1101
1102 #[test]
1103 fn save_ask_rule_shortcut_approves_once_with_rule() {
1104 let mut view = ApprovalView::new(shell_request());
1105
1106 let action = view.handle_key(create_key_event(KeyCode::Char('s')));
1107 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1108 decision,
1109 persistent_rules,
1110 ..
1111 }) = action
1112 else {
1113 panic!("expected approval decision");
1114 };
1115
1116 assert_eq!(decision, ReviewDecision::Approved);
1117 assert_eq!(
1118 persistent_rules,
1119 vec![ToolAskRule::exec_shell("cargo test --workspace")]
1120 );
1121 }
1122
1123 #[test]
1124 fn save_file_ask_rule_shortcut_emits_file_rule() {
1125 // `S` on a write_file approval approves once and carries the exact
1126 // workspace-relative file rule for persistence.
1127 let mut view = ApprovalView::new(destructive_request());
1128
1129 let action = view.handle_key(create_key_event(KeyCode::Char('S')));
1130 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1131 decision,
1132 persistent_rules,
1133 ..
1134 }) = action
1135 else {
1136 panic!("expected approval decision");
1137 };
1138
1139 assert_eq!(decision, ReviewDecision::Approved);
1140 assert_eq!(
1141 persistent_rules,
1142 vec![ToolAskRule::file_path("write_file", "src/main.rs")]
1143 );
1144 }
1145
1146 #[test]
1147 fn persistent_allow_option_approves_once_with_exact_repo_rule() {
1148 let mut view = ApprovalView::new(shell_request());
1149
1150 let action = view.handle_key(create_key_event(KeyCode::Char('p')));
1151 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1152 decision,
1153 persistent_rules,
1154 ..
1155 }) = action
1156 else {
1157 panic!("expected approval decision");
1158 };
1159
1160 assert_eq!(decision, ReviewDecision::Approved);
1161 assert_eq!(
1162 persistent_rules,
1163 vec![
1164 ToolAskRule::exec_shell("cargo test --workspace")
1165 .into_exact_workspace_allow("/workspace")
1166 ]
1167 );
1168 }
1169
1170 #[test]
1171 fn persistent_allow_shortcut_is_ignored_for_dangerous_command() {
1172 let request = critical_request();
1173 assert!(request.persistent_allow_rules.is_empty());
1174 let mut view = ApprovalView::new(request);
1175
1176 assert!(matches!(
1177 view.handle_key(create_key_event(KeyCode::Char('p'))),
1178 ViewAction::None
1179 ));
1180 }
1181
1182 #[test]
1183 fn repo_law_request_does_not_build_a_persistent_allow_candidate() {
1184 let request = ApprovalRequest::new(
1185 "test-id",
1186 "edit_file",
1187 "Repo law holds this write: protected path (matched Cargo.toml, .codewhale/constitution.json)",
1188 &json!({"path": "Cargo.toml", "old": "a", "new": "b"}),
1189 "tool:edit_file",
1190 );
1191
1192 assert!(request.is_repo_law_prompt());
1193 assert!(request.persistent_allow_rules.is_empty());
1194 assert!(!request.can_save_allow_rule());
1195 assert_eq!(request.allow_rule_save_preview(), None);
1196 }
1197
1198 #[test]
1199 fn save_ask_rule_shortcut_is_ignored_without_rule() {
1200 let mut view = ApprovalView::new(benign_request());
1201
1202 let action = view.handle_key(create_key_event(KeyCode::Char('s')));
1203
1204 assert!(matches!(action, ViewAction::None));
1205 }
1206
1207 #[test]
1208 fn benign_one_key_approves_via_numeric_pad() {
1209 let mut view = ApprovalView::new(benign_request());
1210 let action = view.handle_key(create_key_event(KeyCode::Char('1')));
1211 assert!(matches!(
1212 action,
1213 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1214 decision: ReviewDecision::Approved,
1215 ..
1216 })
1217 ));
1218 }
1219
1220 #[test]
1221 fn benign_enter_denies_by_default() {
1222 let mut view = ApprovalView::new(benign_request());
1223 let action = view.handle_key(create_key_event(KeyCode::Enter));
1224 assert!(matches!(
1225 action,
1226 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1227 decision: ReviewDecision::Denied,
1228 ..
1229 })
1230 ));
1231 }
1232
1233 #[test]
1234 fn mouse_click_renders_and_approves_inline_option() {
1235 let mut view = ApprovalView::new(benign_request());
1236 let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal");
1237 terminal
1238 .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
1239 .expect("render approval prompt");
1240 let rect = view.row_hitboxes.borrow()[0];
1241 let action = view.handle_mouse(MouseEvent {
1242 kind: MouseEventKind::Down(MouseButton::Left),
1243 column: rect.x,
1244 row: rect.y,
1245 modifiers: KeyModifiers::NONE,
1246 });
1247 assert!(matches!(
1248 action,
1249 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1250 decision: ReviewDecision::Approved,
1251 ..
1252 })
1253 ));
1254 }
1255
1256 #[test]
1257 fn tiny_localized_approval_keeps_every_action_and_hitbox() {
1258 const WIDTH: u16 = 40;
1259 const HEIGHT: u16 = 12;
1260 let expected = [
1261 ReviewDecision::Approved,
1262 ReviewDecision::ApprovedForSession,
1263 ReviewDecision::Approved,
1264 ReviewDecision::Denied,
1265 ReviewDecision::Abort,
1266 ];
1267
1268 for &locale in Locale::shipped() {
1269 let rendered_view = ApprovalView::new_for_locale(destructive_request(), locale);
1270 let rendered = render_lines(&rendered_view, WIDTH, HEIGHT).join("\n");
1271 assert_approval_key_badges_visible(&rendered);
1272 assert!(
1273 rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
1274 "missing details chord for {locale:?}:\n{rendered}"
1275 );
1276
1277 for (index, expected_decision) in expected.iter().enumerate() {
1278 let mut view = ApprovalView::new_for_locale(destructive_request(), locale);
1279 let mut terminal =
1280 Terminal::new(TestBackend::new(WIDTH, HEIGHT)).expect("test terminal");
1281 terminal
1282 .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
1283 .expect("render localized approval prompt");
1284
1285 let hitboxes = view.row_hitboxes.borrow().clone();
1286 assert_eq!(hitboxes.len(), expected.len(), "{locale:?}: {hitboxes:?}");
1287 for hitbox in &hitboxes {
1288 assert!(hitbox.height > 0, "{locale:?}: {hitboxes:?}");
1289 assert!(hitbox.right() <= WIDTH, "{locale:?}: {hitboxes:?}");
1290 assert!(hitbox.bottom() <= HEIGHT, "{locale:?}: {hitboxes:?}");
1291 }
1292 for pair in hitboxes.windows(2) {
1293 assert!(pair[0].bottom() <= pair[1].y, "{locale:?}: {hitboxes:?}");
1294 }
1295
1296 let rect = hitboxes[index];
1297 let action = view.handle_mouse(MouseEvent {
1298 kind: MouseEventKind::Down(MouseButton::Left),
1299 column: rect.x,
1300 row: rect.y,
1301 modifiers: KeyModifiers::NONE,
1302 });
1303 let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { decision, .. }) = action
1304 else {
1305 panic!("click {index} did not decide for {locale:?}");
1306 };
1307 assert_eq!(decision, *expected_decision, "{locale:?} option {index}");
1308 }
1309 }
1310 }
1311
1312 #[test]
1313 fn benign_a_two_approves_for_session() {
1314 for code in [KeyCode::Char('a'), KeyCode::Char('A'), KeyCode::Char('2')] {
1315 let mut view = ApprovalView::new(benign_request());
1316 let action = view.handle_key(create_key_event(code));
1317 assert!(
1318 matches!(
1319 action,
1320 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1321 decision: ReviewDecision::ApprovedForSession,
1322 ..
1323 })
1324 ),
1325 "expected ApprovedForSession for {code:?}"
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn benign_n_d_three_all_deny() {
1332 for code in [
1333 KeyCode::Char('n'),
1334 KeyCode::Char('N'),
1335 KeyCode::Char('d'),
1336 KeyCode::Char('D'),
1337 KeyCode::Char('3'),
1338 ] {
1339 let mut view = ApprovalView::new(benign_request());
1340 let action = view.handle_key(create_key_event(code));
1341 assert!(
1342 matches!(
1343 action,
1344 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1345 decision: ReviewDecision::Denied,
1346 ..
1347 })
1348 ),
1349 "expected Denied for {code:?}"
1350 );
1351 }
1352 }
1353
1354 #[test]
1355 fn benign_esc_aborts() {
1356 let mut view = ApprovalView::new(benign_request());
1357 let action = view.handle_key(create_key_event(KeyCode::Esc));
1358 assert!(matches!(
1359 action,
1360 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1361 decision: ReviewDecision::Abort,
1362 ..
1363 })
1364 ));
1365 }
1366
1367 #[test]
1368 fn test_approval_view_enter_uses_selected_option() {
1369 let mut view = ApprovalView::new(benign_request());
1370
1371 // The semantic default is Deny; navigate once to Abort and commit it.
1372 view.select_next();
1373 assert_eq!(view.current_option(), ApprovalOption::Abort);
1374
1375 let action = view.handle_key(create_key_event(KeyCode::Enter));
1376 assert!(matches!(
1377 action,
1378 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1379 decision: ReviewDecision::Abort,
1380 ..
1381 })
1382 ));
1383 }
1384
1385 #[test]
1386 fn test_approval_view_navigation_keys() {
1387 let mut view = ApprovalView::new(benign_request());
1388
1389 view.handle_key(create_key_event(KeyCode::Up));
1390 assert_eq!(view.current_option(), ApprovalOption::ApproveAlways);
1391
1392 view.handle_key(create_key_event(KeyCode::Down));
1393 assert_eq!(view.current_option(), ApprovalOption::Deny);
1394
1395 view.handle_key(create_key_event(KeyCode::Down));
1396 assert_eq!(view.current_option(), ApprovalOption::Abort);
1397
1398 view.handle_key(create_key_event(KeyCode::Char('j')));
1399 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
1400
1401 view.handle_key(create_key_event(KeyCode::Char('k')));
1402 assert_eq!(view.current_option(), ApprovalOption::Abort);
1403 }
1404
1405 #[test]
1406 fn test_approval_view_view_params() {
1407 // Bare `v` must not open details (TUI-DOG-002).
1408 let mut view = ApprovalView::new(benign_request());
1409 let action = view.handle_key(create_key_event(KeyCode::Char('v')));
1410 assert!(matches!(action, ViewAction::None));
1411
1412 let mut view = ApprovalView::new(benign_request());
1413 let action = view.handle_key(create_key_event(KeyCode::Char('V')));
1414 assert!(matches!(action, ViewAction::None));
1415
1416 // Alt+V / Option+V opens the params pager.
1417 let mut view = ApprovalView::new(benign_request());
1418 let action = view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
1419 assert!(matches!(
1420 action,
1421 ViewAction::Emit(ViewEvent::OpenTextPager { .. })
1422 ));
1423 }
1424
1425 #[test]
1426 fn edit_file_details_pager_includes_complete_search_replace_preview() {
1427 let request = ApprovalRequest::new(
1428 "test-id",
1429 "edit_file",
1430 "Edit a file on disk",
1431 &json!({
1432 "path": "src/lib.rs",
1433 "search": " old_1();\r\n\told_2();\nold 3();\nold_4();\nold_5();\n",
1434 "replace": "\tnew_1();\nnew 2();\r\nnew_3();\nnew_4();\nnew_5();"
1435 }),
1436 "tool:edit_file",
1437 );
1438 let mut view = ApprovalView::new(request);
1439
1440 let action = view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
1441 let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) = action else {
1442 panic!("Alt+V should open the edit details pager");
1443 };
1444
1445 let expected_preview = [
1446 "Preview:",
1447 "replace this",
1448 "- \"\\x20\\x20old_1();\\r\\n\"",
1449 "- \"\\told_2();\\n\"",
1450 "- \"old\\x20\\x203();\\n\"",
1451 "- \"old_4();\\n\"",
1452 "- \"old_5();\\n\"",
1453 "with this",
1454 "+ \"\\tnew_1();\\n\"",
1455 "+ \"new\\x20\\x202();\\r\\n\"",
1456 "+ \"new_3();\\n\"",
1457 "+ \"new_4();\\n\"",
1458 "+ \"new_5();\"",
1459 ]
1460 .join("\n");
1461 assert!(
1462 content.contains(&expected_preview),
1463 "details pager omitted part of the edit preview:\n{content}"
1464 );
1465
1466 let pager = crate::tui::pager::PagerView::from_text("Tool Params", &content, 200);
1467 let displayed = pager.body_text();
1468 assert!(
1469 displayed.contains(&expected_preview),
1470 "details pager display changed exact whitespace or line endings:\n{displayed}"
1471 );
1472 }
1473
1474 #[test]
1475 fn edit_file_details_pager_localizes_preview_headers_for_every_locale() {
1476 for &locale in Locale::shipped() {
1477 let request = ApprovalRequest::new(
1478 "test-id",
1479 "edit_file",
1480 "Edit a file on disk",
1481 &json!({
1482 "path": "src/lib.rs",
1483 "search": "old();",
1484 "replace": "new();"
1485 }),
1486 "tool:edit_file",
1487 );
1488 let mut view = ApprovalView::new_for_locale(request, locale);
1489
1490 let action = view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
1491 let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) = action else {
1492 panic!("Alt+V should open the edit details pager for {locale:?}");
1493 };
1494 let expected_headers = format!(
1495 "{}:\n{}\n- \"old();\"\n{}\n+ \"new();\"",
1496 tr(locale, MessageId::ApprovalLabelPreview),
1497 tr(locale, MessageId::ApprovalLabelReplaceThis),
1498 tr(locale, MessageId::ApprovalLabelWithThis),
1499 );
1500
1501 assert!(
1502 content.contains(&expected_headers),
1503 "details pager did not localize edit preview headers for {locale:?}:\n{content}"
1504 );
1505 }
1506 }
1507
1508 #[test]
1509 fn test_approval_view_current_decision_mapping() {
1510 let mut view = ApprovalView::new(benign_request());
1511
1512 view.selected = 0;
1513 assert_eq!(view.current_decision(), ReviewDecision::Approved);
1514 view.selected = 1;
1515 assert_eq!(view.current_decision(), ReviewDecision::ApprovedForSession);
1516 view.selected = 2;
1517 assert_eq!(view.current_decision(), ReviewDecision::Denied);
1518 view.selected = 3;
1519 assert_eq!(view.current_decision(), ReviewDecision::Abort);
1520 }
1521
1522 /// One request per row ordering in `ApprovalOption::order_for`, so an
1523 /// index-based default would be caught drifting on at least one of them.
1524 fn one_request_per_card_shape() -> Vec<ApprovalRequest> {
1525 vec![
1526 benign_request(),
1527 shell_request(),
1528 ApprovalRequest::new(
1529 "wf-default",
1530 "workflow",
1531 "Launch workflow",
1532 &json!({
1533 "action": "start",
1534 "plan": {
1535 "goal": "risky",
1536 "risk": "elevated",
1537 "children": [{ "prompt": "go", "type": "implementer" }]
1538 }
1539 }),
1540 "tool:workflow",
1541 ),
1542 ]
1543 }
1544
1545 #[test]
1546 fn default_selection_denies_on_every_card_shape() {
1547 for request in one_request_per_card_shape() {
1548 let view = ApprovalView::new_for_locale(request, Locale::En);
1549 assert_eq!(view.current_option(), ApprovalOption::Deny);
1550 }
1551 }
1552
1553 #[test]
1554 fn allow_once_default_selection_preselects_approve_once() {
1555 for request in one_request_per_card_shape() {
1556 let view = ApprovalView::new_with_default_selection(
1557 request,
1558 Locale::En,
1559 ApprovalDefaultSelection::AllowOnce,
1560 );
1561 assert_eq!(view.current_option(), ApprovalOption::ApproveOnce);
1562 assert_eq!(view.current_decision(), ReviewDecision::Approved);
1563 }
1564 }
1565
1566 #[test]
1567 fn approval_config_resolves_default_selection() {
1568 let bare: crate::config::Config = toml::from_str("").expect("empty config");
1569 assert_eq!(
1570 bare.approval_default_selection(),
1571 ApprovalDefaultSelection::Deny
1572 );
1573
1574 let opted_in: crate::config::Config =
1575 toml::from_str("[approval]\ndefault_selection = \"allow_once\"").expect("approval table");
1576 assert_eq!(
1577 opted_in.approval_default_selection(),
1578 ApprovalDefaultSelection::AllowOnce
1579 );
1580
1581 assert!(
1582 toml::from_str::<crate::config::Config>("[approval]\ndefault_selection = \"allow_always\"")
1583 .is_err()
1584 );
1585 }
1586
1587 // ========================================================================
1588 // ApprovalView Tests — Destructive Variant (one-step approve with warning)
1589 // ========================================================================
1590
1591 #[test]
1592 fn destructive_request_routes_destructive() {
1593 let view = ApprovalView::new(destructive_request());
1594 assert_eq!(view.risk(), RiskLevel::Destructive);
1595 }
1596
1597 #[test]
1598 fn destructive_y_first_press_approves_once() {
1599 for code in [KeyCode::Char('y'), KeyCode::Char('Y')] {
1600 let mut view = ApprovalView::new(destructive_request());
1601
1602 let action = view.handle_key(create_key_event(code));
1603 assert!(
1604 matches!(
1605 action,
1606 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1607 decision: ReviewDecision::Approved,
1608 ..
1609 })
1610 ),
1611 "expected Approved for {code:?}"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn destructive_enter_denies_by_default() {
1618 let mut view = ApprovalView::new(destructive_request());
1619
1620 // The persistent-allow row changes numeric indices, but the semantic
1621 // default still starts at Deny.
1622 assert_eq!(view.current_option(), ApprovalOption::Deny);
1623 let action = view.handle_key(create_key_event(KeyCode::Enter));
1624 assert!(matches!(
1625 action,
1626 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1627 decision: ReviewDecision::Denied,
1628 ..
1629 })
1630 ));
1631 }
1632
1633 #[test]
1634 fn destructive_navigation_then_enter_commits_highlighted_abort() {
1635 let mut view = ApprovalView::new(destructive_request());
1636
1637 view.handle_key(create_key_event(KeyCode::Down));
1638 assert_eq!(view.current_option(), ApprovalOption::Abort);
1639 let action = view.handle_key(create_key_event(KeyCode::Enter));
1640 assert!(matches!(
1641 action,
1642 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1643 decision: ReviewDecision::Abort,
1644 ..
1645 })
1646 ));
1647 }
1648
1649 #[test]
1650 fn destructive_unrelated_key_keeps_modal_open() {
1651 let mut view = ApprovalView::new(destructive_request());
1652
1653 let action = view.handle_key(create_key_event(KeyCode::Char('q')));
1654 assert!(matches!(action, ViewAction::None));
1655 }
1656
1657 #[test]
1658 fn destructive_a_first_press_approves_for_session() {
1659 for code in [KeyCode::Char('a'), KeyCode::Char('A')] {
1660 let mut view = ApprovalView::new(destructive_request());
1661
1662 let action = view.handle_key(create_key_event(code));
1663 assert!(
1664 matches!(
1665 action,
1666 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1667 decision: ReviewDecision::ApprovedForSession,
1668 ..
1669 })
1670 ),
1671 "expected ApprovedForSession for {code:?}"
1672 );
1673 }
1674 }
1675
1676 #[test]
1677 fn destructive_deny_commits_immediately() {
1678 // Deny commits immediately — the user is rejecting the tool.
1679 for code in [
1680 KeyCode::Char('n'),
1681 KeyCode::Char('N'),
1682 KeyCode::Char('d'),
1683 KeyCode::Char('D'),
1684 ] {
1685 let mut view = ApprovalView::new(destructive_request());
1686 let action = view.handle_key(create_key_event(code));
1687 assert!(
1688 matches!(
1689 action,
1690 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1691 decision: ReviewDecision::Denied,
1692 ..
1693 })
1694 ),
1695 "expected Denied for {code:?}"
1696 );
1697 }
1698 }
1699
1700 #[test]
1701 fn destructive_esc_aborts_immediately() {
1702 let mut view = ApprovalView::new(destructive_request());
1703 let action = view.handle_key(create_key_event(KeyCode::Esc));
1704 assert!(matches!(
1705 action,
1706 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
1707 decision: ReviewDecision::Abort,
1708 ..
1709 })
1710 ));
1711 }
1712
1713 // ========================================================================
1714 // Render approval-card smoke tests — keep the visual contract honest.
1715 // ========================================================================
1716
1717 fn render_lines(view: &ApprovalView, w: u16, h: u16) -> Vec<String> {
1718 use ratatui::buffer::Buffer;
1719 use ratatui::layout::Rect;
1720 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
1721 ModalView::render(view, Rect::new(0, 0, w, h), &mut buf);
1722 (0..buf.area.height)
1723 .map(|row| {
1724 (0..buf.area.width)
1725 .map(|col| buf[(col, row)].symbol().to_string())
1726 .collect::<String>()
1727 })
1728 .collect()
1729 }
1730
1731 fn compact_rendered_text(lines: &[String]) -> String {
1732 lines.join("\n").replace(' ', "")
1733 }
1734
1735 fn assert_approval_key_badges_visible(joined: &str) {
1736 for badge in ["[1 / y]", "[2 / a]", "[3 / d / n]", "[Esc]"] {
1737 assert!(
1738 joined.contains(badge),
1739 "missing key badge {badge}:\n{joined}"
1740 );
1741 }
1742 }
1743
1744 #[test]
1745 fn web_run_risk_is_param_aware() {
1746 // search/query is benign; open/click fetch arbitrary URLs -> destructive.
1747 assert_eq!(
1748 classify_risk("web_run", ToolCategory::Network, &json!({"search": "rust"})),
1749 RiskLevel::Benign
1750 );
1751 assert_eq!(
1752 classify_risk(
1753 "web_run",
1754 ToolCategory::Network,
1755 &json!({"open": [{"ref": "https://evil.example"}]})
1756 ),
1757 RiskLevel::Destructive
1758 );
1759 assert_eq!(
1760 classify_risk(
1761 "web_run",
1762 ToolCategory::Network,
1763 &json!({"click": [{"ref": "1"}]})
1764 ),
1765 RiskLevel::Destructive
1766 );
1767 }
1768
1769 #[test]
1770 fn stakes_split_routine_elevated_critical() {
1771 assert_eq!(benign_request().stakes(), ApprovalStakes::Routine);
1772 assert_eq!(destructive_request().stakes(), ApprovalStakes::Elevated);
1773 assert_eq!(shell_request().stakes(), ApprovalStakes::Elevated);
1774 assert_eq!(critical_request().stakes(), ApprovalStakes::Critical);
1775 // Publish-like shell is critical in every origin.
1776 let publish = ApprovalRequest::new(
1777 "test-id",
1778 "exec_shell",
1779 "Run a shell command",
1780 &json!({"command": "git push origin main"}),
1781 "tool:exec_shell",
1782 );
1783 assert_eq!(publish.stakes(), ApprovalStakes::Critical);
1784 }
1785
1786 #[test]
1787 fn agent_tool_is_classified_and_renders_calm() {
1788 assert_eq!(get_tool_category("agent"), ToolCategory::Agent);
1789
1790 let request = ApprovalRequest::new(
1791 "test-id",
1792 "agent",
1793 "Start a sub-agent",
1794 &json!({"action": "start", "type": "explore", "prompt": "map the workspace"}),
1795 "tool:agent",
1796 );
1797 assert_eq!(request.category, ToolCategory::Agent);
1798 assert_eq!(request.stakes(), ApprovalStakes::Elevated);
1799
1800 let view = ApprovalView::new(request);
1801 let lines = render_lines(&view, 100, 40);
1802 let joined = lines.join("\n");
1803 assert!(joined.contains("APPROVAL"), "{joined}");
1804 assert!(!joined.contains("DESTRUCTIVE"), "{joined}");
1805 assert!(
1806 !joined.contains("not classified"),
1807 "agent must not render the unknown-tool warning:\n{joined}"
1808 );
1809 assert!(joined.contains("Action"), "{joined}");
1810 assert!(joined.contains("start"), "{joined}");
1811 assert!(joined.contains("explore"), "{joined}");
1812 assert!(joined.contains("map the workspace"), "{joined}");
1813 }
1814
1815 #[test]
1816 fn agent_status_and_peek_are_benign() {
1817 for action in ["status", "peek", "list"] {
1818 let request = ApprovalRequest::new(
1819 "test-id",
1820 "agent",
1821 "Inspect a sub-agent",
1822 &json!({"action": action, "agent_id": "agent_1"}),
1823 "tool:agent",
1824 );
1825 assert_eq!(request.risk, RiskLevel::Benign, "{action}");
1826 assert_eq!(request.stakes(), ApprovalStakes::Routine, "{action}");
1827 }
1828 }
1829
1830 #[test]
1831 fn render_benign_includes_review_badge_and_selection_hint() {
1832 let view = ApprovalView::new(benign_request());
1833 let lines = render_lines(&view, 100, 40);
1834 let joined = lines.join("\n");
1835 assert!(joined.contains("REVIEW"), "missing REVIEW badge:\n{joined}");
1836 assert_approval_key_badges_visible(&joined);
1837 // The selection prose moved into the per-option key badges; the footer
1838 // keeps only the escape-hatch hints.
1839 assert!(
1840 joined.contains("Pg↑/↓ review"),
1841 "footer controls hint missing:\n{joined}"
1842 );
1843 assert!(joined.contains("read_file"));
1844 }
1845
1846 #[test]
1847 fn approval_footer_hints_use_muted_contrast_tier() {
1848 // #3380: the footer key hints ("Pg↑/↓ review · Alt+V/⌥V details · Esc abort")
1849 // must render one contrast tier above TEXT_HINT — TEXT_MUTED, the same
1850 // color the app-wide ActionHint modal footers use for labels.
1851 use codewhale_palette as palette;
1852 use ratatui::buffer::Buffer;
1853 use ratatui::layout::Rect;
1854
1855 let view = ApprovalView::new(benign_request());
1856 let (w, h) = (100u16, 40u16);
1857 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
1858 ModalView::render(&view, Rect::new(0, 0, w, h), &mut buf);
1859
1860 let target: Vec<String> = "Pg↑/↓ review".chars().map(|c| c.to_string()).collect();
1861 let mut found = None;
1862 for y in 0..h {
1863 let symbols: Vec<String> = (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect();
1864 for x in 0..=(w as usize - target.len()) {
1865 if symbols[x..x + target.len()] == target[..] {
1866 found = Some((u16::try_from(x).expect("column fits"), y));
1867 }
1868 }
1869 }
1870 let (x, y) = found.expect("footer key hints must be rendered");
1871 assert_eq!(
1872 buf[(x, y)].fg,
1873 palette::TEXT_MUTED,
1874 "footer key hints must use the muted (not hint) contrast tier"
1875 );
1876 }
1877
1878 #[test]
1879 fn render_elevated_write_is_calm_and_compact() {
1880 // Ordinary state-touching work (a file write) renders as a calm
1881 // APPROVAL ask: no DESTRUCTIVE badge, no policy dossier, no
1882 // impact/category taxonomy — that detail stays one details chord away.
1883 let view = ApprovalView::new(destructive_request());
1884 let lines = render_lines(&view, 100, 40);
1885 let joined = lines.join("\n");
1886 assert!(joined.contains("APPROVAL"), "missing calm badge:\n{joined}");
1887 assert!(
1888 !joined.contains("DESTRUCTIVE"),
1889 "routine write must not scream DESTRUCTIVE:\n{joined}"
1890 );
1891 assert_approval_key_badges_visible(&joined);
1892 assert!(
1893 joined.contains("Pg↑/↓ review"),
1894 "footer controls hint missing:\n{joined}"
1895 );
1896 assert!(
1897 !joined.contains("active approval policy"),
1898 "policy prose is critical-only:\n{joined}"
1899 );
1900 assert!(
1901 !joined.contains("Impact:"),
1902 "impact dossier is critical-only:\n{joined}"
1903 );
1904 assert!(
1905 !joined.contains("Type:"),
1906 "category taxonomy is critical-only:\n{joined}"
1907 );
1908 assert!(joined.contains("write_file"));
1909 }
1910
1911 #[test]
1912 fn render_critical_shows_warning_badge_and_policy_semantics() {
1913 // Genuinely destructive work keeps the strong styling and the
1914 // policy/cancel semantics.
1915 let view = ApprovalView::new(critical_request());
1916 let lines = render_lines(&view, 100, 40);
1917 let joined = lines.join("\n");
1918 assert!(
1919 joined.contains("DESTRUCTIVE"),
1920 "missing DESTRUCTIVE badge:\n{joined}"
1921 );
1922 assert_approval_key_badges_visible(&joined);
1923 assert!(
1924 joined.contains("active approval policy"),
1925 "missing policy/review-rule semantics:\n{joined}"
1926 );
1927 assert!(
1928 joined.contains("Deny rejects only this tool call"),
1929 "missing deny-vs-abort semantics:\n{joined}"
1930 );
1931 assert!(joined.contains("rm -rf"));
1932 }
1933
1934 #[test]
1935 fn render_elevated_zh_hans_is_calm_and_localized() {
1936 let view = ApprovalView::new_for_locale(destructive_request(), Locale::ZhHans);
1937 let lines = render_lines(&view, 100, 40);
1938 let joined = compact_rendered_text(&lines);
1939 assert!(
1940 joined.contains("需要批准"),
1941 "missing zh calm badge:\n{joined}"
1942 );
1943 assert!(
1944 !joined.contains("破坏性"),
1945 "routine write must not use the destructive zh badge:\n{joined}"
1946 );
1947 assert!(
1948 joined.contains("Pg↑/↓回看"),
1949 "missing zh footer controls hint:\n{joined}"
1950 );
1951 assert!(
1952 !joined.contains("影响:"),
1953 "impact dossier is critical-only:\n{joined}"
1954 );
1955 assert!(
1956 joined.contains("仅允许本次"),
1957 "missing zh approve option:\n{joined}"
1958 );
1959 }
1960
1961 #[test]
1962 fn approval_review_and_save_hints_stay_on_one_row_at_80_columns() {
1963 for &locale in Locale::shipped() {
1964 let view = ApprovalView::new_for_locale(destructive_request(), locale);
1965 let lines = render_lines(&view, 80, 40);
1966 let review_rows = lines
1967 .iter()
1968 .filter(|line| line.contains("Pg↑/↓"))
1969 .collect::<Vec<_>>();
1970
1971 assert_eq!(
1972 review_rows.len(),
1973 1,
1974 "expected one approval review-hint row for {locale:?}:\n{}",
1975 lines.join("\n")
1976 );
1977 let controls = review_rows[0];
1978 assert!(
1979 controls.contains("Esc") && controls.contains(" s "),
1980 "review, abort, and save-rule hints wrapped for {locale:?}:\n{}",
1981 lines.join("\n")
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn render_critical_zh_hans_localizes_security_copy() {
1988 let view = ApprovalView::new_for_locale(critical_request(), Locale::ZhHans);
1989 let lines = render_lines(&view, 100, 40);
1990 let joined = compact_rendered_text(&lines);
1991 assert!(
1992 joined.contains("破坏性"),
1993 "missing zh risk badge:\n{joined}"
1994 );
1995 assert!(
1996 joined.contains("影响:"),
1997 "missing zh impact label:\n{joined}"
1998 );
1999 assert!(
2000 joined.contains("规则:"),
2001 "missing zh policy semantics:\n{joined}"
2002 );
2003 assert!(
2004 joined.contains("仅允许本次"),
2005 "missing zh approve option:\n{joined}"
2006 );
2007 }
2008
2009 #[test]
2010 fn render_takeover_card_fills_most_of_area() {
2011 // The card should be wider than the old 65-cell popup whenever
2012 // the terminal can hold it; this guards against a regression
2013 // back to the centered popup.
2014 let view = ApprovalView::new(benign_request());
2015 let lines = render_lines(&view, 120, 40);
2016 // Find the widest non-blank rendered row.
2017 let widest = lines
2018 .iter()
2019 .map(|l| l.trim_end_matches(' ').len())
2020 .max()
2021 .unwrap_or(0);
2022 assert!(
2023 widest >= 80,
2024 "takeover card too narrow: widest row = {widest} cells"
2025 );
2026 }
2027
2028 // ========================================================================
2029 // ElevationView Tests
2030 // ========================================================================
2031
2032 #[test]
2033 fn test_elevation_view_initial_state() {
2034 let request =
2035 ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false);
2036 let view = ElevationView::new(request, Locale::En);
2037 assert_eq!(view.selected, 0);
2038 }
2039
2040 #[test]
2041 fn test_elevation_view_keybindings() {
2042 let request =
2043 ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true);
2044 let mut view = ElevationView::new(request, Locale::En);
2045
2046 let action = view.handle_key(create_key_event(KeyCode::Char('n')));
2047 assert!(matches!(
2048 action,
2049 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
2050 option: ElevationOption::WithNetwork,
2051 ..
2052 })
2053 ));
2054
2055 let request =
2056 ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true);
2057 let mut view = ElevationView::new(request, Locale::En);
2058 let action = view.handle_key(create_key_event(KeyCode::Char('w')));
2059 assert!(matches!(
2060 action,
2061 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
2062 option: ElevationOption::WithWriteAccess(_),
2063 ..
2064 })
2065 ));
2066
2067 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
2068 let mut view = ElevationView::new(request, Locale::En);
2069 let action = view.handle_key(create_key_event(KeyCode::Char('f')));
2070 assert!(matches!(
2071 action,
2072 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
2073 option: ElevationOption::FullAccess,
2074 ..
2075 })
2076 ));
2077
2078 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
2079 let mut view = ElevationView::new(request, Locale::En);
2080 let action = view.handle_key(create_key_event(KeyCode::Esc));
2081 assert!(matches!(
2082 action,
2083 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
2084 option: ElevationOption::Abort,
2085 ..
2086 })
2087 ));
2088
2089 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
2090 let mut view = ElevationView::new(request, Locale::En);
2091 let action = view.handle_key(create_key_event(KeyCode::Char('a')));
2092 assert!(matches!(
2093 action,
2094 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
2095 option: ElevationOption::Abort,
2096 ..
2097 })
2098 ));
2099 }
2100
2101 #[test]
2102 fn test_elevation_view_navigation() {
2103 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
2104 let mut view = ElevationView::new(request, Locale::En);
2105
2106 assert_eq!(view.selected, 0);
2107
2108 view.handle_key(create_key_event(KeyCode::Down));
2109 assert_eq!(view.selected, 1);
2110
2111 view.handle_key(create_key_event(KeyCode::Up));
2112 assert_eq!(view.selected, 0);
2113
2114 view.handle_key(create_key_event(KeyCode::Char('j')));
2115 assert_eq!(view.selected, 1);
2116
2117 view.handle_key(create_key_event(KeyCode::Char('k')));
2118 assert_eq!(view.selected, 0);
2119 }
2120
2121 #[test]
2122 fn test_elevation_view_enter_uses_selected_option() {
2123 let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
2124 let mut view = ElevationView::new(request, Locale::En);
2125
2126 view.handle_key(create_key_event(KeyCode::Down));
2127 assert_eq!(view.selected, 1);
2128
2129 let action = view.handle_key(create_key_event(KeyCode::Enter));
2130 assert!(matches!(
2131 action,
2132 ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
2133 option: ElevationOption::FullAccess,
2134 ..
2135 })
2136 ));
2137 }
2138
2139 fn render_elevation_lines(view: &ElevationView, w: u16, h: u16) -> Vec<String> {
2140 use ratatui::buffer::Buffer;
2141 use ratatui::layout::Rect;
2142 let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
2143 view.render(Rect::new(0, 0, w, h), &mut buf);
2144 (0..h)
2145 .map(|row| {
2146 (0..w)
2147 .map(|col| buf[(col, row)].symbol().to_string())
2148 .collect::<String>()
2149 })
2150 .collect()
2151 }
2152
2153 fn compact_elevation_text(lines: &[String]) -> String {
2154 lines.join("\n").replace(' ', "")
2155 }
2156
2157 fn elevation_shell_request() -> ElevationRequest {
2158 ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false)
2159 }
2160
2161 #[test]
2162 fn test_elevation_render_en_has_expected_strings() {
2163 let view = ElevationView::new(elevation_shell_request(), Locale::En);
2164 let lines = render_elevation_lines(&view, 70, 22);
2165 let joined = compact_elevation_text(&lines);
2166 assert!(
2167 joined.contains("SandboxDenied"),
2168 "missing en title:\n{joined}"
2169 );
2170 assert!(joined.contains("Tool:"), "missing en tool label:\n{joined}");
2171 assert!(joined.contains("Cmd:"), "missing en cmd label:\n{joined}");
2172 assert!(
2173 joined.contains("Reason:"),
2174 "missing en reason label:\n{joined}"
2175 );
2176 }
2177
2178 #[test]
2179 fn elevation_always_paints_every_option_including_the_safe_exit() {
2180 // The card used to be a fixed 22 rows centred on the frame, with no scroll
2181 // rail and no truncation hint, so the option list ran off the bottom and
2182 // `Abort` — the only choice that grants nothing — was unreachable by sight
2183 // at every terminal size. Options are reserved now; the denial detail is
2184 // what shortens.
2185 let view = ElevationView::new(elevation_shell_request(), Locale::En);
2186 for (w, h) in [(70, 22), (80, 24), (100, 32), (140, 40), (60, 16)] {
2187 let joined = compact_elevation_text(&render_elevation_lines(&view, w, h));
2188 for option in ["Abort", "Fullaccess", "Allowoutboundnetwork"] {
2189 assert!(
2190 joined.contains(option),
2191 "{w}x{h}: option '{option}' is not on screen:\n{joined}"
2192 );
2193 }
2194 assert!(
2195 joined.contains("SandboxDenied"),
2196 "{w}x{h}: the card lost its title:\n{joined}"
2197 );
2198 }
2199 }
2200
2201 #[test]
2202 fn test_elevation_render_zh_hans_localizes_copy() {
2203 let view = ElevationView::new(elevation_shell_request(), Locale::ZhHans);
2204 let lines = render_elevation_lines(&view, 70, 22);
2205 let joined = compact_elevation_text(&lines);
2206 assert!(joined.contains("沙箱拒绝"), "missing zh title:\n{joined}");
2207 assert!(
2208 joined.contains("工具:"),
2209 "missing zh tool label:\n{joined}"
2210 );
2211 assert!(joined.contains("命令:"), "missing zh cmd label:\n{joined}");
2212 assert!(
2213 joined.contains("原因:"),
2214 "missing zh reason label:\n{joined}"
2215 );
2216 assert!(
2217 joined.contains("批准后的影响"),
2218 "missing zh impact header:\n{joined}"
2219 );
2220 let en_artifacts = [
2221 "SandboxDenied",
2222 "Tool:",
2223 "Cmd:",
2224 "Reason:",
2225 "Impactifapproved",
2226 "Choosehowtoproceed",
2227 "Allowoutboundnetwork",
2228 "Allowextrawriteaccess",
2229 "Fullaccess",
2230 "Abort",
2231 ];
2232 for artifact in &en_artifacts {
2233 assert!(
2234 !joined.contains(artifact),
2235 "English leak '{artifact}' in zh rendering:\n{joined}"
2236 );
2237 }
2238 }
2239
2240 #[test]
2241 fn test_elevation_render_ja_has_translated_copy() {
2242 let view = ElevationView::new(elevation_shell_request(), Locale::Ja);
2243 let lines = render_elevation_lines(&view, 70, 22);
2244 let joined = compact_elevation_text(&lines);
2245 assert!(
2246 joined.contains("サンドボックス拒否"),
2247 "missing ja title:\n{joined}"
2248 );
2249 assert!(
2250 joined.contains("ツール:"),
2251 "missing ja tool label:\n{joined}"
2252 );
2253 assert!(
2254 joined.contains("コマンド:"),
2255 "missing ja cmd label:\n{joined}"
2256 );
2257 assert!(
2258 joined.contains("理由:"),
2259 "missing ja reason label:\n{joined}"
2260 );
2261 for eng in &["SandboxDenied", "Tool:", "Cmd:", "Reason:"] as &[&str] {
2262 assert!(
2263 !joined.contains(eng),
2264 "English leak '{eng}' in ja:\n{joined}"
2265 );
2266 }
2267 }
2268
2269 #[test]
2270 fn test_elevation_render_zh_hant_has_translated_copy() {
2271 let view = ElevationView::new(elevation_shell_request(), Locale::ZhHant);
2272 let lines = render_elevation_lines(&view, 70, 22);
2273 let joined = compact_elevation_text(&lines);
2274 assert!(
2275 joined.contains("沙箱拒絕"),
2276 "missing zh-Hant title:\n{joined}"
2277 );
2278 assert!(
2279 joined.contains("工具:"),
2280 "missing zh-Hant tool label:\n{joined}"
2281 );
2282 assert!(
2283 joined.contains("命令:"),
2284 "missing zh-Hant cmd label:\n{joined}"
2285 );
2286 assert!(
2287 joined.contains("原因:"),
2288 "missing zh-Hant reason label:\n{joined}"
2289 );
2290 }
2291
2292 // ========================================================================
2293 // ElevationOption Tests
2294 // ========================================================================
2295
2296 #[test]
2297 fn test_elevation_option_labels() {
2298 assert_eq!(
2299 ElevationOption::WithNetwork.label(),
2300 "Allow outbound network"
2301 );
2302 assert_eq!(
2303 ElevationOption::FullAccess.label(),
2304 "Full access (filesystem + network)"
2305 );
2306 assert!(
2307 ElevationOption::WithWriteAccess(vec![])
2308 .label()
2309 .contains("write")
2310 );
2311 assert_eq!(ElevationOption::Abort.label(), "Abort");
2312 }
2313
2314 #[test]
2315 fn test_elevation_option_descriptions() {
2316 assert!(
2317 ElevationOption::WithNetwork
2318 .description()
2319 .contains("network")
2320 );
2321 assert!(
2322 ElevationOption::FullAccess
2323 .description()
2324 .contains("filesystem and network access")
2325 );
2326 assert!(ElevationOption::Abort.description().contains("Cancel"));
2327 }
2328
2329 #[test]
2330 fn test_elevation_option_to_policy() {
2331 let cwd = PathBuf::from("/tmp/test");
2332
2333 let policy = ElevationOption::WithNetwork.to_policy(&cwd);
2334 assert!(matches!(
2335 policy,
2336 SandboxPolicy::WorkspaceWrite {
2337 network_access: true,
2338 ..
2339 }
2340 ));
2341
2342 let policy = ElevationOption::FullAccess.to_policy(&cwd);
2343 assert!(matches!(policy, SandboxPolicy::DangerFullAccess));
2344
2345 let paths = vec![PathBuf::from("/tmp/test/src")];
2346 let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd);
2347 assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
2348 }
2349
2350 // ========================================================================
2351 // ElevationRequest Tests
2352 // ========================================================================
2353
2354 #[test]
2355 fn test_elevation_request_for_shell_with_network_block() {
2356 let request = ElevationRequest::for_shell(
2357 "test-id",
2358 "curl example.com",
2359 "network blocked",
2360 true,
2361 false,
2362 );
2363
2364 assert_eq!(request.tool_id, "test-id");
2365 assert_eq!(request.tool_name, "exec_shell");
2366 assert!(request.command.is_some());
2367 assert!(request.denial_reason.contains("network"));
2368 assert!(
2369 request
2370 .options
2371 .iter()
2372 .any(|o| matches!(o, ElevationOption::WithNetwork))
2373 );
2374 }
2375
2376 #[test]
2377 fn test_elevation_request_for_shell_with_write_block() {
2378 let request =
2379 ElevationRequest::for_shell("test-id", "rm -rf /tmp", "write blocked", false, true);
2380
2381 assert_eq!(request.tool_id, "test-id");
2382 assert!(
2383 request
2384 .options
2385 .iter()
2386 .any(|o| matches!(o, ElevationOption::WithWriteAccess(_)))
2387 );
2388 }
2389
2390 #[test]
2391 fn test_elevation_request_generic() {
2392 let request = ElevationRequest::generic("test-id", "some_tool", "permission denied");
2393
2394 assert_eq!(request.tool_id, "test-id");
2395 assert_eq!(request.tool_name, "some_tool");
2396 assert!(request.command.is_none());
2397 assert!(
2398 request
2399 .options
2400 .iter()
2401 .any(|o| matches!(o, ElevationOption::WithNetwork))
2402 );
2403 assert!(
2404 request
2405 .options
2406 .iter()
2407 .any(|o| matches!(o, ElevationOption::FullAccess))
2408 );
2409 assert!(
2410 request
2411 .options
2412 .iter()
2413 .any(|o| matches!(o, ElevationOption::Abort))
2414 );
2415 }
2416
2417 // ========================================================================
2418 // Workflow elevated plan approval card (#4126)
2419 // ========================================================================
2420
2421 #[test]
2422 fn workflow_tool_is_agent_category_and_shows_plan_card_fields() {
2423 assert_eq!(get_tool_category("workflow"), ToolCategory::Agent);
2424 let request = ApprovalRequest::new(
2425 "wf-1",
2426 "workflow",
2427 "Launch workflow",
2428 &json!({
2429 "action": "start",
2430 "plan": {
2431 "goal": "ship the fix",
2432 "risk": "writes",
2433 "token_budget": 80_000,
2434 "children": [
2435 {
2436 "id": "impl",
2437 "label": "builder",
2438 "prompt": "edit files",
2439 "type": "implementer",
2440 "mode": "read_write"
2441 }
2442 ]
2443 }
2444 }),
2445 "tool:workflow",
2446 );
2447 assert_eq!(request.category, ToolCategory::Agent);
2448 let details = request.prominent_detail_items(Locale::En);
2449 let labels: Vec<_> = details.iter().map(|d| d.label.as_str()).collect();
2450 assert!(labels.contains(&"Goal"), "{labels:?}");
2451 assert!(labels.contains(&"Children"), "{labels:?}");
2452 assert!(labels.contains(&"Writes"), "{labels:?}");
2453 assert!(labels.contains(&"Shell"), "{labels:?}");
2454 assert!(labels.contains(&"Network"), "{labels:?}");
2455 assert!(labels.contains(&"Budget"), "{labels:?}");
2456 assert!(
2457 details
2458 .iter()
2459 .any(|d| d.label == "Goal" && d.value.contains("ship the fix")),
2460 "{details:?}"
2461 );
2462 assert!(
2463 details
2464 .iter()
2465 .any(|d| d.label == "Writes" && d.value == "yes"),
2466 "{details:?}"
2467 );
2468 assert!(
2469 request
2470 .impacts
2471 .iter()
2472 .any(|i| i.contains("Approve to launch")),
2473 "{:?}",
2474 request.impacts
2475 );
2476
2477 let view = ApprovalView::new(request);
2478 assert!(view.is_workflow_plan_approval());
2479 assert_eq!(view.current_option(), ApprovalOption::Deny);
2480 assert_eq!(view.current_decision(), ReviewDecision::Denied);
2481 }
2482
2483 #[test]
2484 fn workflow_plan_card_edit_plan_and_cancel_keys() {
2485 let request = ApprovalRequest::new(
2486 "wf-2",
2487 "workflow",
2488 "Launch workflow",
2489 &json!({
2490 "action": "start",
2491 "plan": {
2492 "goal": "risky",
2493 "risk": "elevated",
2494 "children": [{ "prompt": "go", "type": "implementer" }]
2495 }
2496 }),
2497 "tool:workflow",
2498 );
2499 let mut view = ApprovalView::new(request);
2500 // [2 / e] → Edit plan → Denied
2501 let action = view.handle_key(create_key_event(KeyCode::Char('e')));
2502 match action {
2503 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { decision, .. }) => {
2504 assert_eq!(decision, ReviewDecision::Denied);
2505 }
2506 other => panic!("expected edit-plan denial, got {other:?}"),
2507 }
2508
2509 let request = ApprovalRequest::new(
2510 "wf-3",
2511 "workflow",
2512 "Launch workflow",
2513 &json!({
2514 "action": "start",
2515 "plan": {
2516 "goal": "risky",
2517 "risk": "elevated",
2518 "children": [{ "prompt": "go", "type": "implementer" }]
2519 }
2520 }),
2521 "tool:workflow",
2522 );
2523 let mut view = ApprovalView::new(request);
2524 let action = view.handle_key(create_key_event(KeyCode::Char('3')));
2525 match action {
2526 ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { decision, .. }) => {
2527 assert_eq!(decision, ReviewDecision::Abort);
2528 }
2529 other => panic!("expected cancel abort, got {other:?}"),
2530 }
2531 }
2532
2533 #[test]
2534 fn canonical_bash_keeps_original_name_but_uses_shell_approval_semantics() {
2535 let request = ApprovalRequest::new_with_intent(
2536 "bash-1",
2537 "Bash",
2538 "Run command",
2539 &json!({"action": "run", "command": "cargo test", "cwd": "/workspace"}),
2540 "tool:Bash",
2541 None,
2542 Path::new("/workspace"),
2543 );
2544
2545 assert_eq!(request.tool_name, "Bash");
2546 assert_eq!(request.category, ToolCategory::Shell);
2547 assert_eq!(request.risk, RiskLevel::Destructive);
2548 assert_eq!(
2549 request.persistent_ask_rules,
2550 vec![ToolAskRule::exec_shell("cargo test")]
2551 );
2552 let details = request.prominent_detail_items(Locale::En);
2553 assert!(
2554 details
2555 .iter()
2556 .any(|detail| detail.label == "Command" && detail.value == "cargo test")
2557 );
2558 }
2559
2560 #[test]
2561 fn canonical_file_mutations_get_legacy_previews_and_scoped_ask_rules() {
2562 let cases = [
2563 (
2564 "write",
2565 json!({
2566 "action": "write",
2567 "path": "/workspace/src/lib.rs",
2568 "content": "pub fn whale() {}\n"
2569 }),
2570 "write_file",
2571 "+ pub fn whale() {}",
2572 ),
2573 (
2574 "edit",
2575 json!({
2576 "action": "edit",
2577 "path": "/workspace/src/lib.rs",
2578 "search": "old",
2579 "replace": "new"
2580 }),
2581 "edit_file",
2582 "- old",
2583 ),
2584 (
2585 "patch",
2586 json!({
2587 "action": "patch",
2588 "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"
2589 }),
2590 "apply_patch",
2591 "-old",
2592 ),
2593 ];
2594
2595 for (action, params, rule_tool, preview_fragment) in cases {
2596 let request = ApprovalRequest::new_with_intent(
2597 action,
2598 "File",
2599 "Mutate file",
2600 &params,
2601 "tool:File",
2602 None,
2603 Path::new("/workspace"),
2604 );
2605 assert_eq!(request.tool_name, "File", "{action}");
2606 assert_eq!(request.category, ToolCategory::FileWrite, "{action}");
2607 assert_eq!(request.risk, RiskLevel::Destructive, "{action}");
2608 assert!(
2609 request
2610 .persistent_ask_rules
2611 .iter()
2612 .any(|rule| rule.tool == rule_tool),
2613 "{action}: {:?}",
2614 request.persistent_ask_rules
2615 );
2616 let preview = request
2617 .prominent_detail_items(Locale::En)
2618 .into_iter()
2619 .find(|detail| detail.label == "Preview")
2620 .expect("canonical file mutation must show a preview");
2621 assert!(
2622 preview.value.contains(preview_fragment),
2623 "{action}: {preview:?}"
2624 );
2625 }
2626 }
2627
2627 lines RUST