1use std::fs;
4use std::path::{Path, PathBuf};
5#[cfg(any(
6 target_os = "macos",
7 target_os = "freebsd",
8 target_os = "openbsd",
9 target_os = "netbsd",
10 target_os = "dragonfly"
11))]
12use std::process::Command;
13
14use super::dangerous_commands::{unwrap_command_prefix, unwrap_command_prefix_with_context};
15
16#[derive(Debug, PartialEq, Eq)]
22struct RmTarget {
23 operand: String,
24 base: Option<PathBuf>,
25}
26
27pub fn recursive_rm_targets_require_approval_in_directory(
41 command: &[String],
42 script: Option<&str>,
43 working_directory: &Path,
44) -> bool {
45 let targets = recursive_rm_targets(command, script, Some(working_directory));
46 if targets.is_empty() {
47 return false;
48 }
49 let Some(mount_points) = mounted_filesystem_mount_points() else {
50 return true;
53 };
54
55 rm_targets_are_sensitive(&targets, &normalized_mount_points(&mount_points))
56}
57
58fn recursive_rm_targets(command: &[String], script: Option<&str>, base: Option<&Path>) -> Vec<RmTarget> {
61 let mut targets = Vec::new();
62 collect_argv_rm_targets(command, base, &mut targets);
63 if let Some(script) = script
64 && script_mentions_rm(script)
65 {
66 collect_script_rm_targets(script, base, &mut targets);
67 }
68 targets
69}
70
71fn script_mentions_rm(script: &str) -> bool {
74 script.contains("rm")
75}
76
77fn collect_argv_rm_targets(command: &[String], base: Option<&Path>, targets: &mut Vec<RmTarget>) {
79 let Some(unwrapped) = unwrap_command_prefix_with_context(command) else {
80 return;
81 };
82 let base = base.filter(|_| !unwrapped.changes_directory);
84 let command = unwrapped.command;
85
86 if let Some(script) = shell_script_argument(command) {
87 collect_script_rm_targets(script, base, targets);
88 return;
89 }
90
91 let Some(executable) = command.first() else {
92 return;
93 };
94 let executable = Path::new(executable)
95 .file_name()
96 .and_then(|name| name.to_str())
97 .unwrap_or(executable);
98 if executable != "rm" {
99 return;
100 }
101
102 let Some(arguments) = command.get(1..) else {
103 return;
104 };
105 let parsed = parse_rm_arguments(arguments);
106 if !parsed.recursive {
107 return;
108 }
109 for operand in parsed.targets {
110 targets.push(RmTarget {
111 operand: operand.to_owned(),
112 base: base.map(Path::to_path_buf),
113 });
114 }
115}
116
117fn shell_script_argument(command: &[String]) -> Option<&str> {
119 let executable = command.first()?;
120 let executable = Path::new(executable)
121 .file_name()
122 .and_then(|name| name.to_str())
123 .unwrap_or(executable);
124 if !matches!(executable, "bash" | "sh" | "zsh") {
125 return None;
126 }
127 let flag = command.get(1)?;
128 if !matches!(flag.as_str(), "-c" | "-lc" | "-ilc") {
129 return None;
130 }
131 command.get(2).map(String::as_str)
132}
133
134fn collect_script_rm_targets(script: &str, base: Option<&Path>, targets: &mut Vec<RmTarget>) {
136 let Some(commands) = parse_rm_shell_commands(script) else {
137 targets.push(RmTarget { operand: String::new(), base: None });
140 return;
141 };
142 let base = base.filter(|_| !commands.iter().any(|nested| changes_shell_directory(nested)));
145 for nested in &commands {
146 collect_argv_rm_targets(nested, base, targets);
147 }
148}
149
150fn rm_targets_are_sensitive(targets: &[RmTarget], mount_points: &[PathBuf]) -> bool {
152 targets.iter().any(|target| rm_target_is_sensitive(target, mount_points))
153}
154
155fn rm_target_is_sensitive(target: &RmTarget, mount_points: &[PathBuf]) -> bool {
156 let Some(resolved) = resolve_target_path(&target.operand, target.base.as_deref()) else {
157 return true;
160 };
161
162 is_known_mount_layout_target(&resolved)
163 || mount_points
164 .iter()
165 .any(|mount_point| is_mount_root_or_top_level_target(&resolved, mount_point))
166}
167
168fn normalized_mount_points(mount_points: &[PathBuf]) -> Vec<PathBuf> {
170 mount_points
171 .iter()
172 .map(|mount_point| {
173 vtcode_commons::paths::canonicalize(mount_point)
174 .unwrap_or_else(|_| vtcode_commons::paths::normalize_path(mount_point))
175 })
176 .collect()
177}
178
179#[derive(Debug, Default, PartialEq, Eq)]
180struct RmArguments<'a> {
181 recursive: bool,
182 targets: Vec<&'a str>,
183}
184
185fn parse_rm_arguments(arguments: &[String]) -> RmArguments<'_> {
186 let mut parsed = RmArguments::default();
187 let mut end_of_options = false;
188
189 for argument in arguments {
190 if end_of_options {
191 parsed.targets.push(argument);
192 continue;
193 }
194
195 if argument == "--" {
200 end_of_options = true;
201 } else if argument == "--recursive" {
202 parsed.recursive = true;
203 } else if argument.starts_with("--") {
204 } else if let Some(flags) = argument
206 .strip_prefix('-')
207 .filter(|flags| !flags.is_empty() && !flags.starts_with('-'))
208 {
209 parsed.recursive |= flags.chars().any(|flag| matches!(flag, 'r' | 'R'));
210 } else {
211 parsed.targets.push(argument);
212 }
213 }
214
215 parsed
216}
217
218fn parse_rm_shell_commands(script: &str) -> Option<Vec<Vec<String>>> {
219 super::shell_parser::parse_shell_commands_tree_sitter(script)
222 .ok()?
223 .into_iter()
224 .map(|command| {
225 command
226 .into_iter()
227 .map(|word| {
228 if word.contains("$'") {
230 return None;
231 }
232 let mut words = shell_words::split(&word).ok()?;
233 if words.len() == 1 { words.pop() } else { None }
234 })
235 .collect()
236 })
237 .collect()
238}
239
240fn changes_shell_directory(command: &[String]) -> bool {
245 let Some(command) = unwrap_command_prefix(command) else {
246 return false;
247 };
248 matches!(command.first().map(String::as_str), Some("cd" | "pushd" | "popd"))
249}
250
251fn resolve_target_path(target: &str, working_directory: Option<&Path>) -> Option<PathBuf> {
252 if target
253 .chars()
254 .any(|character| matches!(character, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}' | '~'))
255 {
256 return None;
257 }
258
259 let target_path = Path::new(target);
260 let candidate = if target_path.is_absolute() {
261 target_path.to_path_buf()
262 } else {
263 let working_directory = working_directory?;
264 let working_directory = if working_directory.is_absolute() {
265 working_directory.to_path_buf()
266 } else {
267 std::env::current_dir().ok()?.join(working_directory)
268 };
269 working_directory.join(target_path)
270 };
271
272 canonicalize_allowing_missing(&candidate)
273}
274
275fn canonicalize_allowing_missing(path: &Path) -> Option<PathBuf> {
276 let mut ancestor = path;
277 let mut missing_components = Vec::new();
278
279 loop {
280 match vtcode_commons::paths::canonicalize(ancestor) {
281 Ok(mut resolved) => {
282 for component in missing_components.iter().rev() {
283 resolved.push(component);
284 }
285 return Some(vtcode_commons::paths::normalize_path(&resolved));
286 }
287 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
288 missing_components.push(ancestor.file_name()?.to_os_string());
289 ancestor = ancestor.parent()?;
290 }
291 Err(_) => return None,
292 }
293 }
294}
295
296fn is_known_mount_layout_target(target: &Path) -> bool {
297 path_is_within_depth(target, Path::new("/Volumes"), 2)
298 || path_is_within_depth(target, Path::new("/media"), 3)
299 || is_wsl_drive_layout_target(target)
300}
301
302fn path_is_within_depth(target: &Path, root: &Path, max_relative_depth: usize) -> bool {
303 target
304 .strip_prefix(root)
305 .is_ok_and(|relative| relative.components().count() <= max_relative_depth)
306}
307
308fn is_wsl_drive_layout_target(target: &Path) -> bool {
309 let root = Path::new("/mnt");
310 if target == root {
311 return true;
312 }
313
314 let Ok(relative) = target.strip_prefix(root) else {
315 return false;
316 };
317 let mut components = relative.components();
318 let Some(std::path::Component::Normal(drive)) = components.next() else {
319 return false;
320 };
321 let Some(drive) = drive.to_str() else {
322 return false;
323 };
324 drive.len() == 1 && drive.bytes().next().is_some_and(|byte| byte.is_ascii_alphabetic()) && components.count() <= 1
325}
326
327fn is_mount_root_or_top_level_target(target: &Path, mount_point: &Path) -> bool {
328 target == mount_point
329 || target.parent() == Some(mount_point)
330 || mount_point.starts_with(target)
332}
333
334#[cfg(target_os = "linux")]
335fn mounted_filesystem_mount_points() -> Option<Vec<PathBuf>> {
336 parse_linux_mount_info(&fs::read_to_string("/proc/self/mountinfo").ok()?)
337}
338
339#[cfg(any(
340 target_os = "macos",
341 target_os = "freebsd",
342 target_os = "openbsd",
343 target_os = "netbsd",
344 target_os = "dragonfly"
345))]
346fn mounted_filesystem_mount_points() -> Option<Vec<PathBuf>> {
347 let output = Command::new("/sbin/mount").output().ok()?;
348 if !output.status.success() {
349 return None;
350 }
351 parse_bsd_mount_output(&String::from_utf8(output.stdout).ok()?)
352}
353
354#[cfg(not(any(
355 target_os = "linux",
356 target_os = "macos",
357 target_os = "freebsd",
358 target_os = "openbsd",
359 target_os = "netbsd",
360 target_os = "dragonfly"
361)))]
362fn mounted_filesystem_mount_points() -> Option<Vec<PathBuf>> {
363 None
364}
365
366#[cfg(any(target_os = "linux", test))]
367fn parse_linux_mount_info(contents: &str) -> Option<Vec<PathBuf>> {
368 parse_mount_points(contents.lines().map(|line| line.split_whitespace().nth(4)))
369}
370
371#[cfg(any(
372 target_os = "macos",
373 target_os = "freebsd",
374 target_os = "openbsd",
375 target_os = "netbsd",
376 target_os = "dragonfly",
377 test
378))]
379fn parse_bsd_mount_output(contents: &str) -> Option<Vec<PathBuf>> {
380 parse_mount_points(contents.lines().map(|line| {
381 let (_, mount_description) = line.split_once(" on ")?;
382 let (mount_point, _) = mount_description.rsplit_once(" (")?;
383 Some(mount_point)
384 }))
385}
386
387#[cfg(any(
388 target_os = "linux",
389 target_os = "macos",
390 target_os = "freebsd",
391 target_os = "openbsd",
392 target_os = "netbsd",
393 target_os = "dragonfly",
394 test
395))]
396fn parse_mount_points<'a>(fields: impl Iterator<Item = Option<&'a str>>) -> Option<Vec<PathBuf>> {
397 let mount_points: Option<Vec<PathBuf>> = fields
398 .map(|field| {
399 let decoded = decode_mount_path(field?)?;
400 let path = PathBuf::from(decoded);
401 path.is_absolute().then_some(path)
402 })
403 .collect();
404 let mount_points = mount_points?;
405 (!mount_points.is_empty()).then_some(mount_points)
406}
407
408#[cfg(any(
409 target_os = "linux",
410 target_os = "macos",
411 target_os = "freebsd",
412 target_os = "openbsd",
413 target_os = "netbsd",
414 target_os = "dragonfly",
415 test
416))]
417fn decode_mount_path(encoded: &str) -> Option<String> {
418 let decoded = encoded
419 .replace("\\040", " ")
420 .replace("\\011", "\t")
421 .replace("\\012", "\n")
422 .replace("\\134", "\\");
423 if decoded.is_empty() {
424 return None;
425 }
426 Some(decoded)
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn command(args: &[&str]) -> Vec<String> {
434 args.iter().map(|argument| (*argument).to_owned()).collect()
435 }
436
437 fn approval_with_mount_points(
440 command: &[String],
441 script: Option<&str>,
442 working_directory: &Path,
443 mount_points: Option<&[PathBuf]>,
444 ) -> bool {
445 let targets = recursive_rm_targets(command, script, Some(working_directory));
446 if targets.is_empty() {
447 return false;
448 }
449 let Some(mount_points) = mount_points else {
450 return true;
451 };
452 rm_targets_are_sensitive(&targets, &normalized_mount_points(mount_points))
453 }
454
455 fn recursive_rm_targets_are_sensitive(
458 command: &[String],
459 working_directory: &Path,
460 mount_points: &[PathBuf],
461 ) -> bool {
462 let targets = recursive_rm_targets(command, None, Some(working_directory));
463 rm_targets_are_sensitive(&targets, &normalized_mount_points(mount_points))
464 }
465
466 #[test]
467 fn rm_parser_reads_recursive_options_and_end_of_options_targets() {
468 for args in [
469 command(&["-r", "/mnt/c"]),
470 command(&["-rf", "/mnt/c"]),
471 command(&["-fiR", "/mnt/c"]),
472 command(&["--recursive", "/mnt/c"]),
473 ] {
474 let parsed = parse_rm_arguments(&args);
475 assert!(parsed.recursive, "expected recursive option: {args:?}");
476 assert_eq!(parsed.targets, ["/mnt/c"]);
477 }
478
479 let args = command(&["--", "-r", "/mnt/c"]);
480 let after_separator = parse_rm_arguments(&args);
481 assert!(!after_separator.recursive);
482 assert_eq!(after_separator.targets, ["-r", "/mnt/c"]);
483
484 let args = command(&["-fiR", "--", "-target", "/mnt/c"]);
485 let parsed = parse_rm_arguments(&args);
486 assert!(parsed.recursive);
487 assert_eq!(parsed.targets, ["-target", "/mnt/c"]);
488 }
489
490 #[test]
491 fn recursive_rm_prompts_for_mount_roots_and_immediate_children() {
492 let mounts = [PathBuf::from("/"), PathBuf::from("/custom/archive")];
493 for target in [
494 "/Applications",
495 "/Volumes",
496 "/Volumes/Backup",
497 "/Volumes/Backup/Photos",
498 "/media",
499 "/media/user/USB/Photos",
500 "/mnt/c",
501 "/mnt/c/Users",
502 "/custom/archive",
503 "/custom/archive/Photos",
504 ] {
505 assert!(
506 recursive_rm_targets_are_sensitive(
507 &command(&["rm", "--recursive", target]),
508 Path::new("/tmp"),
509 &mounts
510 ),
511 "expected prompt for {target}"
512 );
513 }
514
515 for invocation in [
516 command(&["rm", "-r", "/Volumes"]),
517 command(&["rm", "-rf", "/Volumes/Backup"]),
518 command(&["rm", "-fiR", "/media/user/USB/Photos"]),
519 command(&["rm", "--recursive", "/mnt/c/Users"]),
520 command(&["rm", "-r", "--", "/custom/archive/Photos"]),
521 command(&["bash", "-lc", "rm --recursive /custom/archive/Photos"]),
522 ] {
523 assert!(
524 recursive_rm_targets_are_sensitive(&invocation, Path::new("/tmp"), &mounts),
525 "expected prompt for {invocation:?}"
526 );
527 }
528
529 for target in [
530 "/Volumes/Backup/Photos/2026",
531 "/custom/archive/Photos/2026",
532 "/tmp/project/target",
533 ] {
534 assert!(
535 !recursive_rm_targets_are_sensitive(&command(&["rm", "-r", target]), Path::new("/tmp"), &mounts),
536 "ordinary nested target should not be newly classified: {target}"
537 );
538 }
539 }
540
541 #[test]
542 fn recursive_rm_resolves_relative_targets_from_working_directory() {
543 let temp_dir = tempfile::tempdir().expect("temporary directory");
544 let mount_point = temp_dir.path().join("drive");
545 fs::create_dir_all(mount_point.join("Projects")).expect("create mount fixture");
546 let mounts = [mount_point.clone()];
547
548 assert!(recursive_rm_targets_are_sensitive(&command(&["rm", "-rf", "Projects"]), &mount_point, &mounts));
549 assert!(recursive_rm_targets_are_sensitive(&command(&["rm", "-r", "--", "Projects"]), &mount_point, &mounts));
550 assert!(!recursive_rm_targets_are_sensitive(
551 &command(&["rm", "--", "-r", "Projects"]),
552 &mount_point,
553 &mounts
554 ));
555 }
556
557 #[test]
558 fn recursive_rm_after_shell_directory_changes_requires_approval() {
559 let project = tempfile::tempdir().expect("project directory");
560 let mounts = [PathBuf::from("/Volumes/Backup")];
561
562 for script in [
563 "cd /Volumes/Backup && rm --recursive Photos",
564 "cd /Volumes/Backup && rm --recursive Photos &&",
565 "'cd' /Volumes/Backup && 'rm' '--recursive' 'Photos'",
566 "$'cd' /Volumes/Backup && rm --recursive Photos",
567 "builtin cd /Volumes/Backup; rm -r Photos",
568 "command -- cd /Volumes/Backup && rm -R Photos",
569 "pushd /Volumes/Backup && rm --recursive Photos",
570 "popd; rm --recursive Photos",
571 "if cd /Volumes/Backup; then rm --recursive Photos; fi",
572 "(cd /Volumes/Backup && rm --recursive Photos)",
573 "cd /Volumes/Backup && sh -c 'rm --recursive Photos'",
574 "sh -c 'cd /Volumes/Backup && rm --recursive Photos'",
575 "rm --recursive Photos; cd /Volumes/Backup",
577 ] {
578 assert!(
579 approval_with_mount_points(&command(&["bash", "-lc", script]), None, project.path(), Some(&mounts),),
580 "relative recursive target needs approval with uncertain directory: {script}"
581 );
582 }
583
584 let absolute_cleanup =
585 format!("cd /Volumes/Backup && rm --recursive '{}'", project.path().join("build/cache").display());
586 for script in [
587 "rm --recursive Photos",
588 "echo cd /Volumes/Backup && rm --recursive Photos",
589 "cd /Volumes/Backup && rm -- Photos",
590 absolute_cleanup.as_str(),
591 ] {
592 assert!(
593 !approval_with_mount_points(&command(&["bash", "-lc", script]), None, project.path(), Some(&mounts),),
594 "known ordinary targets or nonrecursive deletes should retain their classification: {script}"
595 );
596 }
597 }
598
599 #[test]
600 fn recursive_rm_preserves_directory_uncertainty_from_command_wrappers() {
601 let project = tempfile::tempdir().expect("project directory");
602 let mounts = [PathBuf::from("/Volumes/Backup")];
603 for invocation in [
604 command(&["sudo", "--chdir", "/Volumes/Backup", "rm", "--recursive", "Photos"]),
605 command(&["sudo", "--chdir=/Volumes/Backup", "rm", "--recursive", "Photos"]),
606 command(&[
607 "env",
608 "MODE=test",
609 "sudo",
610 "--chdir=/Volumes/Backup",
611 "rm",
612 "--recursive",
613 "Photos",
614 ]),
615 command(&["sudo", "--chdir=/Volumes/Backup", "sh", "-c", "rm --recursive Photos"]),
616 ] {
617 assert!(!super::super::command_might_be_dangerous(&invocation));
618 assert!(
619 approval_with_mount_points(&invocation, None, project.path(), Some(&mounts)),
620 "a directory-changing wrapper must invalidate relative target context: {invocation:?}"
621 );
622 }
623
624 let absolute_cleanup = project.path().join("build/cache");
625 for invocation in [
626 command(&["sudo", "-u", "root", "rm", "--recursive", "Photos"]),
627 command(&["sudo", "-C", "3", "rm", "--recursive", "Photos"]),
628 command(&[
629 "sudo",
630 "--chdir=/Volumes/Backup",
631 "rm",
632 "--recursive",
633 absolute_cleanup.to_str().unwrap(),
634 ]),
635 ] {
636 assert!(
637 !approval_with_mount_points(&invocation, None, project.path(), Some(&mounts)),
638 "ordinary wrappers and absolute project targets retain their classification: {invocation:?}"
639 );
640 }
641 }
642
643 #[test]
644 fn recursive_rm_with_unresolved_targets_fails_closed() {
645 let command = command(&["rm", "--recursive", "$TARGET"]);
646 assert!(recursive_rm_targets_are_sensitive(&command, Path::new("/tmp"), &[]));
647 assert!(approval_with_mount_points(&command, None, Path::new("/tmp"), Some(&[])));
648 }
649
650 #[test]
651 fn ordinary_recursive_project_cleanup_stays_unchanged_with_mount_data() {
652 let temp_dir = tempfile::tempdir().expect("temporary directory");
653 let target = temp_dir.path().join("build/cache");
654 fs::create_dir_all(&target).expect("create project fixture");
655 let command = command(&["rm", "--recursive", "build/cache"]);
656 let mount_points = [PathBuf::from("/"), PathBuf::from("/custom/archive")];
657
658 assert!(!approval_with_mount_points(&command, None, temp_dir.path(), Some(&mount_points)));
659 }
660
661 #[test]
662 fn recursive_rm_requires_approval_when_mount_data_is_unavailable() {
663 let command = command(&["rm", "--recursive", "build/cache"]);
664 assert!(approval_with_mount_points(&command, None, Path::new("/tmp/project"), None));
665 }
666
667 #[test]
668 fn mount_tables_decode_paths_and_reject_incomplete_data() {
669 let linux =
670 parse_linux_mount_info("36 25 0:32 / /mnt/My\\040Drive rw - ext4 /dev/sda1 rw\n").expect("valid mountinfo");
671 assert_eq!(linux, [PathBuf::from("/mnt/My Drive")]);
672 assert!(parse_bsd_mount_output("/dev/disk3 on /Volumes/My\\040Drive (apfs, local)\n").is_some());
673 assert!(parse_linux_mount_info("malformed\n").is_none());
674 assert!(parse_bsd_mount_output("malformed\n").is_none());
675 }
676
677 #[test]
682 fn compound_command_scripts_cannot_hide_a_recursive_delete() {
683 let project = tempfile::tempdir().expect("project directory");
684 let mounts = [PathBuf::from("/Volumes/Backup")];
685 let compound = command(&["cd", "/Volumes/Backup", "&&", "rm", "--recursive", "Photos"]);
686
687 assert!(
689 !approval_with_mount_points(&compound, None, project.path(), Some(&mounts)),
690 "the argv view alone cannot classify a compound command"
691 );
692 assert!(
694 approval_with_mount_points(
695 &compound,
696 Some("cd /Volumes/Backup && rm --recursive Photos"),
697 project.path(),
698 Some(&mounts)
699 ),
700 "a compound command must not hide a mounted recursive delete"
701 );
702
703 assert!(!approval_with_mount_points(
707 &command(&["cd", "/Volumes/Backup", "&&", "rm", "Photos"]),
708 Some("cd /Volumes/Backup && rm Photos"),
709 project.path(),
710 Some(&mounts)
711 ));
712 assert!(!approval_with_mount_points(
713 &command(&["rm", "--recursive", "build"]),
714 Some("rm --recursive build"),
715 project.path(),
716 Some(&mounts)
717 ));
718 }
719
720 #[test]
722 fn builtin_prefix_cannot_hide_a_recursive_delete() {
723 let project = tempfile::tempdir().expect("project directory");
724 let mounts = [PathBuf::from("/Volumes/Backup")];
725 for invocation in [
726 command(&["command", "rm", "--recursive", "/Volumes/Backup/Photos"]),
727 command(&["builtin", "rm", "--recursive", "/Volumes/Backup/Photos"]),
728 command(&["command", "--", "rm", "--recursive", "/Volumes/Backup/Photos"]),
729 ] {
730 assert!(
731 approval_with_mount_points(&invocation, None, project.path(), Some(&mounts)),
732 "a builtin prefix must not hide a mounted recursive delete: {invocation:?}"
733 );
734 }
735
736 assert!(!approval_with_mount_points(&command(&["command", "-v", "rm"]), None, project.path(), Some(&mounts)));
738 }
739
740 #[test]
742 fn script_view_keeps_ordinary_commands_unclassified() {
743 let project = tempfile::tempdir().expect("project directory");
744 let mounts = [PathBuf::from("/Volumes/Backup"), PathBuf::from("/")];
745
746 for script in [
747 "ls -la",
748 "cargo fmt --all",
749 "echo 'rm -rf /'",
750 "grep -r rm src",
751 "rm build/cache",
752 "rm -f build/cache",
753 ] {
754 assert!(
755 !approval_with_mount_points(
756 &command(&["sh", "-c", script]),
757 Some(script),
758 project.path(),
759 Some(&mounts),
760 ),
761 "ordinary command must not require approval: {script}"
762 );
763 }
764 }
765
766 #[test]
769 fn malformed_recursive_script_fails_closed() {
770 let project = tempfile::tempdir().expect("project directory");
771 let mounts = [PathBuf::from("/Volumes/Backup")];
772 let malformed = "rm --recursive 'unterminated";
773
774 assert!(approval_with_mount_points(
775 &command(&["sh", "-c", malformed]),
776 Some(malformed),
777 project.path(),
778 Some(&mounts),
779 ));
780 }
781}