1use std::io::{self, IsTerminal, Read, Write};
2use std::process::{Child, Command, Output, Stdio};
3
4use crate::core::config;
5use crate::core::slow_log;
6use crate::core::tokens::count_tokens;
7
8fn wait_with_limits(
21 mut child: Child,
22 max_bytes: usize,
23 timeout: std::time::Duration,
24 kill_group: bool,
25) -> Output {
26 let stdout_pipe = child.stdout.take();
27 let stderr_pipe = child.stderr.take();
28 let start = std::time::Instant::now();
29
30 let stdout_handle = std::thread::spawn(move || {
31 let Some(mut pipe) = stdout_pipe else {
32 return (Vec::new(), false);
33 };
34 let mut buf = Vec::with_capacity(max_bytes.min(64 * 1024));
35 let mut chunk = [0u8; 8192];
36 loop {
37 match pipe.read(&mut chunk) {
38 Ok(0) => break,
39 Ok(n) => {
40 if buf.len() + n > max_bytes {
41 let remaining = max_bytes.saturating_sub(buf.len());
42 buf.extend_from_slice(&chunk[..remaining]);
43 return (buf, true);
44 }
45 buf.extend_from_slice(&chunk[..n]);
46 }
47 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
48 Err(_) => break,
49 }
50 }
51 (buf, false)
52 });
53
54 let stderr_handle = std::thread::spawn(move || {
55 let Some(mut pipe) = stderr_pipe else {
56 return Vec::new();
57 };
58 let mut buf = Vec::new();
59 let mut chunk = [0u8; 4096];
60 const STDERR_LIMIT: usize = 512 * 1024;
61 loop {
62 match pipe.read(&mut chunk) {
63 Ok(0) => break,
64 Ok(n) => {
65 if buf.len() + n > STDERR_LIMIT {
66 break;
67 }
68 buf.extend_from_slice(&chunk[..n]);
69 }
70 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
71 Err(_) => break,
72 }
73 }
74 buf
75 });
76
77 let mut timed_out = false;
78 loop {
79 if start.elapsed() > timeout {
80 kill_child(&mut child, kill_group);
81 let _ = child.wait();
82 timed_out = true;
83 break;
84 }
85 match child.try_wait() {
86 Ok(Some(_)) | Err(_) => break,
87 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
88 }
89 }
90
91 let (mut stdout_buf, stdout_truncated) = stdout_handle.join().unwrap_or_default();
92 let stderr_buf = stderr_handle.join().unwrap_or_default();
93
94 if timed_out || stdout_truncated {
95 let notice = format!(
96 "\n[lean-ctx: output truncated at {} MB / {}s limit]\n",
97 max_bytes / (1024 * 1024),
98 timeout.as_secs()
99 );
100 stdout_buf.extend_from_slice(notice.as_bytes());
101 }
102
103 let status = child.wait().unwrap_or_else(|_| {
104 std::process::Command::new("false")
105 .status()
106 .expect("cannot run `false`")
107 });
108
109 Output {
110 status,
111 stdout: stdout_buf,
112 stderr: stderr_buf,
113 }
114}
115
116fn kill_child(child: &mut Child, kill_group: bool) {
120 #[cfg(unix)]
121 if kill_group {
122 let pgid = child.id() as libc::pid_t;
123 if pgid > 0 {
124 unsafe { libc::killpg(pgid, libc::SIGKILL) };
126 }
127 }
128 #[cfg(not(unix))]
129 let _ = kill_group;
130 let _ = child.kill();
131}
132
133#[cfg(test)]
134mod nested_lean_ctx_exec_tests {
135 #[test]
136 fn collapses_single_nested_c() {
137 assert_eq!(
138 super::collapse_nested_lean_ctx_exec("lean-ctx -c 'git status'").as_deref(),
139 Some("git status")
140 );
141 }
142
143 #[test]
144 fn collapses_repeated_nested_c() {
145 assert_eq!(
146 super::collapse_nested_lean_ctx_exec("lean-ctx -c 'lean-ctx -c \"git status\"'")
147 .as_deref(),
148 Some("git status")
149 );
150 }
151
152 #[test]
153 fn preserves_inner_shell_quoting() {
154 assert_eq!(
155 super::collapse_nested_lean_ctx_exec("lean-ctx -c \"git commit -m 'hello world'\"")
156 .as_deref(),
157 Some("git commit -m 'hello world'")
158 );
159 assert_eq!(
160 super::collapse_nested_lean_ctx_exec("lean-ctx -c git commit -m 'hello world'")
161 .as_deref(),
162 Some("git commit -m 'hello world'")
163 );
164 }
165
166 #[test]
167 fn collapses_exec_alias_and_path() {
168 assert_eq!(
169 super::collapse_nested_lean_ctx_exec("/usr/local/bin/lean-ctx exec 'git status'")
170 .as_deref(),
171 Some("git status")
172 );
173 }
174
175 #[test]
176 fn leaves_non_wrappers_alone() {
177 assert!(super::collapse_nested_lean_ctx_exec("git status").is_none());
178 }
179
180 #[test]
181 fn wrapped_nested_wrapper_still_owns_one_compression_pass() {
182 let _lock = crate::core::data_dir::test_env_lock();
183 crate::test_env::set_var(super::super::reentry::WRAP_MARKER, "1");
184
185 assert!(super::should_delegate_wrapped_to_shell_default(false));
186 assert!(
187 !super::should_delegate_wrapped_to_shell_default(true),
188 "collapsed nested wrappers must not fall through to raw shell-default path"
189 );
190
191 crate::test_env::remove_var(super::super::reentry::WRAP_MARKER);
192 }
193}
194
195const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
197const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
199
200fn exec_limits(command: &str) -> (usize, std::time::Duration) {
201 let max_bytes = if is_heavy_command(command) {
202 HEAVY_MAX_BYTES
203 } else {
204 DEFAULT_MAX_BYTES
205 };
206 (max_bytes, shell_timeout(command))
207}
208
209#[must_use]
222pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
223 shell_timeout_with_override(command, None)
224}
225
226const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000; #[must_use]
236pub(crate) fn shell_timeout_with_override(
237 command: &str,
238 override_ms: Option<u64>,
239) -> std::time::Duration {
240 if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
241 return std::time::Duration::from_millis(ms);
242 }
243 if let Some(ms) = override_ms.filter(|n| *n > 0) {
244 return std::time::Duration::from_millis(ms.min(MAX_CALL_TIMEOUT_MS));
245 }
246 if is_heavy_command(command) {
247 if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
248 .or_else(|| config::Config::load().shell_heavy_timeout_secs)
249 {
250 return std::time::Duration::from_secs(secs);
251 }
252 HEAVY_TIMEOUT
253 } else {
254 if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
255 .or_else(|| config::Config::load().shell_timeout_secs)
256 {
257 return std::time::Duration::from_secs(secs);
258 }
259 DEFAULT_TIMEOUT
260 }
261}
262
263fn env_u64(var: &str) -> Option<u64> {
266 std::env::var(var)
267 .ok()
268 .and_then(|v| v.parse::<u64>().ok())
269 .filter(|n| *n > 0)
270}
271
272fn is_heavy_command(command: &str) -> bool {
273 let cmd = command.trim();
274 let lower = cmd.to_lowercase();
275 static HEAVY_PREFIXES: &[&str] = &[
276 "cargo build",
277 "cargo test",
278 "cargo nextest",
279 "cargo clippy",
280 "cargo check",
281 "cargo install",
282 "cargo bench",
283 "npm run build",
284 "npm install",
285 "npm ci",
286 "pnpm install",
287 "pnpm build",
288 "yarn install",
289 "yarn build",
290 "bun install",
291 "make",
292 "cmake",
293 "bazel build",
294 "bazel test",
295 "gradle build",
296 "gradle test",
297 "mvn package",
298 "mvn install",
299 "mvn test",
300 "go build",
301 "go test",
302 "dotnet build",
303 "dotnet test",
304 "swift build",
305 "swift test",
306 "flutter build",
307 "docker build",
308 "docker compose build",
309 "pip install",
310 "poetry install",
311 "uv sync",
312 "bundle install",
313 "mix compile",
314 "git commit",
321 "git push",
322 "mise ",
326 "just ",
327 ];
328 HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
329}
330
331pub fn exec_argv(args: &[String]) -> i32 {
337 if args.is_empty() {
338 return 127;
339 }
340
341 let joined = super::platform::join_command(args);
346
347 if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
351 return exec(&u.rebuild());
352 }
353
354 if let Some(code) = allowlist_gate(&joined) {
360 return code;
361 }
362
363 if super::reentry::should_pass_through() {
364 return exec_direct(args);
365 }
366
367 let cfg = config::Config::load();
368 let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
369
370 if policy.is_protected() {
371 let code = exec_direct(args);
372 crate::core::tool_lifecycle::record_shell_command(0, 0);
373 return code;
374 }
375
376 let code = exec_direct(args);
377 crate::core::tool_lifecycle::record_shell_command(0, 0);
378 code
379}
380
381fn exec_direct(args: &[String]) -> i32 {
382 let mut cmd = Command::new(&args[0]);
383 cmd.args(&args[1..])
384 .stdin(Stdio::inherit())
385 .stdout(Stdio::inherit())
386 .stderr(Stdio::inherit());
387 super::reentry::mark_child(&mut cmd);
388 super::platform::apply_utf8_locale(&mut cmd);
389 let status = cmd.status();
390
391 match status {
392 Ok(s) => s.code().unwrap_or(1),
393 Err(e) => {
394 tracing::error!("lean-ctx: failed to execute: {e}");
395 127
396 }
397 }
398}
399
400fn allowlist_must_enforce() -> bool {
413 let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
414 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
415 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
416 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
417}
418
419fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
422 if hook_child {
423 return true;
424 }
425 if warn_only {
426 return false;
427 }
428 !stderr_is_tty
429}
430
431fn stdout_is_regular_file() -> bool {
444 #[cfg(unix)]
445 {
446 use std::os::unix::io::{AsRawFd, FromRawFd};
447 let fd = io::stdout().as_raw_fd();
448 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
451 file.metadata().is_ok_and(|m| m.is_file())
452 }
453 #[cfg(windows)]
454 {
455 use std::os::windows::io::{AsRawHandle, FromRawHandle};
456 let handle = io::stdout().as_raw_handle();
457 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
460 file.metadata().is_ok_and(|m| m.is_file())
461 }
462 #[cfg(not(any(unix, windows)))]
463 {
464 false
465 }
466}
467
468fn allowlist_gate(command: &str) -> Option<i32> {
476 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
477 if allowlist_must_enforce() {
478 eprintln!("{msg}");
479 eprintln!(
480 "lean-ctx: command blocked by shell allowlist. \
481 Allow it permanently: lean-ctx allow <cmd> — or set \
482 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
483 );
484 return Some(126);
485 }
486 if io::stderr().is_terminal() {
491 tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
492 } else {
493 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
494 }
495 }
496 None
497}
498
499pub fn exec(command: &str) -> i32 {
500 let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
506 let mut collapsed_nested = false;
507 let collapsed;
508 let command = unwrapped.as_deref().unwrap_or(command);
509 let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
510 collapsed_nested = true;
511 collapsed = c;
512 collapsed.as_str()
513 } else {
514 command
515 };
516
517 if let Some(code) = allowlist_gate(command) {
518 return code;
519 }
520
521 let (shell, shell_flag) = super::platform::shell_and_flag();
522 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
523 let command = command.as_str();
524
525 if super::reentry::is_disabled() {
526 return exec_inherit(command, &shell, &shell_flag);
527 }
528 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
529 return exec_shell_default(command, &shell, &shell_flag);
530 }
531
532 let cfg = config::Config::load();
533 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
534 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
535
536 if raw_mode {
537 return exec_inherit_tracked(command, &shell, &shell_flag);
538 }
539
540 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
541
542 if policy == super::output_policy::OutputPolicy::Passthrough {
544 return exec_inherit_tracked(command, &shell, &shell_flag);
545 }
546
547 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
551 return exec_inherit_tracked(command, &shell, &shell_flag);
552 }
553
554 if !force_compress {
555 if io::stdout().is_terminal() {
556 return exec_inherit_tracked(command, &shell, &shell_flag);
557 }
558 let code = exec_inherit(command, &shell, &shell_flag);
559 crate::core::tool_lifecycle::record_shell_command(0, 0);
560 return code;
561 }
562
563 if stdout_is_regular_file() {
572 return exec_inherit_tracked(command, &shell, &shell_flag);
573 }
574
575 exec_buffered(command, &shell, &shell_flag, &cfg)
576}
577
578fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
579 let mut current = command.trim().to_string();
580 let mut changed = false;
581
582 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
583 if next == current {
584 break;
585 }
586 current = next;
587 changed = true;
588 }
589
590 changed.then_some(current)
591}
592
593fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
594 super::reentry::is_wrapped() && !collapsed_nested
598}
599
600fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
601 let words = split_simple_shell_words(command)?;
602 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
603 return None;
604 }
605 if words[1].value != "-c" && words[1].value != "exec" {
606 return None;
607 }
608 if words[2..].iter().any(|w| {
609 matches!(
610 w.value.as_str(),
611 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
612 )
613 }) {
614 return None;
615 }
616 if words.len() == 3 {
617 Some(words[2].value.trim().to_string())
618 } else {
619 Some(command[words[2].start..].trim().to_string())
620 }
621}
622
623fn is_lean_ctx_bin(word: &str) -> bool {
624 std::path::Path::new(word)
625 .file_name()
626 .and_then(|name| name.to_str())
627 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
628}
629
630struct SimpleShellWord {
631 value: String,
632 start: usize,
633}
634
635fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
636 let mut words = Vec::new();
637 let mut current = String::new();
638 let mut current_start: Option<usize> = None;
639 let mut chars = command.char_indices().peekable();
640 let mut quote: Option<char> = None;
641
642 while let Some((idx, ch)) = chars.next() {
643 match quote {
644 Some('\'') if ch == '\'' => quote = None,
645 Some('"') if ch == '"' => quote = None,
646 None if ch == '\'' || ch == '"' => {
647 current_start.get_or_insert(idx);
648 quote = Some(ch);
649 }
650 Some('"') | None if ch == '\\' => {
651 current_start.get_or_insert(idx);
652 if let Some((_, next)) = chars.next() {
653 current.push(next);
654 }
655 }
656 None if ch.is_whitespace() => {
657 if let Some(start) = current_start.take() {
658 words.push(SimpleShellWord {
659 value: std::mem::take(&mut current),
660 start,
661 });
662 }
663 }
664 Some(_) | None => {
665 current_start.get_or_insert(idx);
666 current.push(ch);
667 }
668 }
669 }
670
671 if quote.is_some() {
672 return None;
673 }
674 if let Some(start) = current_start {
675 words.push(SimpleShellWord {
676 value: current,
677 start,
678 });
679 }
680 (!words.is_empty()).then_some(words)
681}
682
683fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
684 let mut cmd = Command::new(shell);
685 cmd.arg(shell_flag)
686 .arg(command)
687 .stdin(Stdio::inherit())
688 .stdout(Stdio::inherit())
689 .stderr(Stdio::inherit());
690 super::reentry::mark_child(&mut cmd);
691 super::platform::apply_utf8_locale(&mut cmd);
692 super::platform::apply_profile_free_env(&mut cmd);
693 let status = cmd.status();
694
695 match status {
696 Ok(s) => s.code().unwrap_or(1),
697 Err(e) => {
698 tracing::error!("lean-ctx: failed to execute: {e}");
699 127
700 }
701 }
702}
703
704fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
705 let mut cmd = Command::new(shell);
706 cmd.arg(shell_flag)
707 .arg(command)
708 .stdin(Stdio::inherit())
709 .stdout(Stdio::inherit())
710 .stderr(Stdio::inherit());
711 super::reentry::clear_shell_default_markers(&mut cmd);
712 super::platform::apply_utf8_locale(&mut cmd);
713 super::platform::apply_profile_free_env(&mut cmd);
714 let status = cmd.status();
715
716 match status {
717 Ok(s) => s.code().unwrap_or(1),
718 Err(e) => {
719 eprintln!("lean-ctx: failed to execute '{command}': {e}");
720 127
721 }
722 }
723}
724
725fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
726 let code = exec_inherit(command, shell, shell_flag);
727 crate::core::tool_lifecycle::record_shell_command(0, 0);
728 code
729}
730
731pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
735
736pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
740 match (stdout.is_empty(), stderr.is_empty()) {
741 (_, true) => stdout.to_string(),
742 (true, false) => stderr.to_string(),
743 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
744 (false, false) => format!("{stdout}\n{stderr}"),
745 }
746}
747
748fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
749 #[cfg(windows)]
750 super::platform::set_console_utf8();
751
752 let start = std::time::Instant::now();
753
754 let mut cmd = Command::new(shell);
755
756 #[cfg(windows)]
757 let ps_tmp_path: Option<tempfile::TempPath>;
758 #[cfg(windows)]
759 {
760 if super::platform::is_powershell(shell) {
761 let ps_script = format!(
762 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
763 command
764 );
765 match tempfile::Builder::new()
769 .prefix("lean-ctx-ps-")
770 .suffix(".ps1")
771 .tempfile()
772 {
773 Ok(tmp) => {
774 let tmp_path = tmp.into_temp_path();
775 let _ = std::fs::write(&tmp_path, &ps_script);
776 cmd.args([
777 "-NoProfile",
778 "-ExecutionPolicy",
779 "Bypass",
780 "-File",
781 &tmp_path.to_string_lossy(),
782 ]);
783 ps_tmp_path = Some(tmp_path);
784 }
785 Err(e) => {
786 tracing::warn!(
787 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
788 );
789 cmd.arg(shell_flag);
790 cmd.arg(command);
791 ps_tmp_path = None;
792 }
793 }
794 } else {
795 cmd.arg(shell_flag);
796 cmd.arg(command);
797 ps_tmp_path = None;
798 }
799 }
800 #[cfg(not(windows))]
801 {
802 cmd.arg(shell_flag);
803 cmd.arg(command);
804 }
805
806 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
807 let isolate = !io::stdin().is_terminal();
818 if isolate {
819 cmd.stdin(Stdio::null());
820 #[cfg(unix)]
821 {
822 use std::os::unix::process::CommandExt as _;
823 cmd.process_group(0);
824 }
825 }
826 super::reentry::mark_child(&mut cmd);
827 super::platform::apply_utf8_locale(&mut cmd);
828 super::platform::apply_profile_free_env(&mut cmd);
829 let child = cmd.spawn();
830
831 let child = match child {
832 Ok(c) => c,
833 Err(e) => {
834 tracing::error!("lean-ctx: failed to execute: {e}");
835 #[cfg(windows)]
836 if let Some(ref tmp) = ps_tmp_path {
837 let _ = std::fs::remove_file(tmp);
838 }
839 return 127;
840 }
841 };
842
843 let (max_bytes, timeout) = exec_limits(command);
844 let output = wait_with_limits(child, max_bytes, timeout, isolate);
845
846 let duration_ms = start.elapsed().as_millis();
847 let exit_code = output.status.code().unwrap_or(1);
848 let stdout = super::platform::decode_output(&output.stdout);
849 let stderr = super::platform::decode_output(&output.stderr);
850
851 let full_output = combine_streams(&stdout, &stderr, exit_code);
852 let input_tokens = count_tokens(&full_output);
853
854 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
857
858 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
861
862 let (compressed, output_tokens) =
863 super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
864
865 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
866
867 if !compressed.is_empty() {
868 let _ = io::stdout().write_all(compressed.as_bytes());
869 if !compressed.ends_with('\n') {
870 let _ = io::stdout().write_all(b"\n");
871 }
872 }
873 let should_tee = super::tee_policy::should_tee(
876 &cfg.tee_mode,
877 exit_code,
878 full_output.trim().is_empty(),
879 input_tokens,
880 output_tokens,
881 );
882 if should_tee
883 && let Some(path) = super::redact::save_tee(command, &full_output)
884 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
885 {
886 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
887 }
888
889 let threshold = cfg.slow_command_threshold_ms;
890 if threshold > 0 && duration_ms >= threshold as u128 {
891 slow_log::record(command, duration_ms, exit_code);
892 }
893
894 #[cfg(windows)]
895 if let Some(ref tmp) = ps_tmp_path {
896 let _ = std::fs::remove_file(tmp);
897 }
898
899 exit_code
900}
901
902#[cfg(test)]
903mod exec_tests {
904 #[test]
905 fn combine_streams_labels_stderr_on_failure() {
906 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
907 assert_eq!(
908 out,
909 format!(
910 "build ok\n{}\nlinker: undefined symbol",
911 super::STDERR_LABEL
912 )
913 );
914 }
915
916 #[test]
917 fn combine_streams_plain_join_on_success() {
918 let out = super::combine_streams("step 1", "warning: noop", 0);
919 assert_eq!(out, "step 1\nwarning: noop");
920 assert!(!out.contains(super::STDERR_LABEL));
921 }
922
923 #[test]
924 fn combine_streams_single_stream_is_unchanged() {
925 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
926 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
927 }
928
929 #[test]
930 fn exec_direct_runs_true() {
931 let code = super::exec_direct(&["true".to_string()]);
932 assert_eq!(code, 0);
933 }
934
935 #[test]
936 fn exec_direct_runs_false() {
937 let code = super::exec_direct(&["false".to_string()]);
938 assert_ne!(code, 0);
939 }
940
941 #[test]
942 fn exec_direct_preserves_args_with_special_chars() {
943 let code = super::exec_direct(&[
944 "echo".to_string(),
945 "hello world".to_string(),
946 "it's here".to_string(),
947 "a \"quoted\" thing".to_string(),
948 ]);
949 assert_eq!(code, 0);
950 }
951
952 #[test]
953 fn exec_direct_nonexistent_returns_127() {
954 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
955 assert_eq!(code, 127);
956 }
957
958 #[test]
959 fn exec_argv_empty_returns_127() {
960 let code = super::exec_argv(&[]);
961 assert_eq!(code, 127);
962 }
963
964 #[test]
965 fn exec_argv_runs_simple_command() {
966 let _lock = crate::core::data_dir::test_env_lock();
967 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
968 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
969 let code = super::exec_argv(&["true".to_string()]);
970 assert_eq!(code, 0);
971 }
972
973 #[test]
974 fn exec_argv_passes_through_when_disabled() {
975 let _lock = crate::core::data_dir::test_env_lock();
976 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
977 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
978 let code = super::exec_argv(&["true".to_string()]);
979 crate::test_env::remove_var("LEAN_CTX_DISABLED");
980 assert_eq!(code, 0);
981 }
982
983 #[test]
987 fn exec_argv_enforces_allowlist_for_disallowed_command() {
988 let _lock = crate::core::data_dir::test_env_lock();
989 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
990 crate::test_env::remove_var("LEAN_CTX_DISABLED");
991 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
992 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
994 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
995
996 let code = super::exec_argv(&["true".to_string()]);
997
998 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
999 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1000
1001 assert_eq!(
1002 code, 126,
1003 "non-allowlisted command must be blocked on the -t track path"
1004 );
1005 }
1006
1007 #[test]
1008 fn exec_argv_allows_allowlisted_command() {
1009 let _lock = crate::core::data_dir::test_env_lock();
1010 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1011 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1012 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1013 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1014
1015 let code = super::exec_argv(&["true".to_string()]);
1016
1017 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1018 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1019
1020 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1021 }
1022
1023 #[test]
1024 fn wait_with_limits_captures_output() {
1025 let child = std::process::Command::new("echo")
1026 .arg("hello")
1027 .stdout(std::process::Stdio::piped())
1028 .stderr(std::process::Stdio::piped())
1029 .spawn()
1030 .unwrap();
1031
1032 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1033 let stdout = String::from_utf8_lossy(&output.stdout);
1034 assert!(
1035 stdout.contains("hello"),
1036 "expected 'hello' in output: {stdout}"
1037 );
1038 assert!(output.status.success());
1039 }
1040
1041 #[test]
1042 fn wait_with_limits_truncates_large_output() {
1043 let child = std::process::Command::new("sh")
1045 .args(["-c", "yes 'aaaa' | head -25000"])
1046 .stdout(std::process::Stdio::piped())
1047 .stderr(std::process::Stdio::piped())
1048 .spawn()
1049 .unwrap();
1050
1051 let output =
1052 super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1053 let stdout = String::from_utf8_lossy(&output.stdout);
1054 assert!(
1055 stdout.contains("[lean-ctx: output truncated"),
1056 "expected truncation notice, got len={}: ...{}",
1057 stdout.len(),
1058 &stdout[stdout.len().saturating_sub(80)..]
1059 );
1060 }
1061
1062 #[test]
1063 fn wait_with_limits_timeout_kills_process() {
1064 let child = std::process::Command::new("sleep")
1065 .arg("60")
1066 .stdout(std::process::Stdio::piped())
1067 .stderr(std::process::Stdio::piped())
1068 .spawn()
1069 .unwrap();
1070
1071 let start = std::time::Instant::now();
1072 let output =
1073 super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1074 let elapsed = start.elapsed();
1075
1076 assert!(
1077 elapsed < std::time::Duration::from_secs(3),
1078 "timeout should kill quickly, took {elapsed:?}"
1079 );
1080 let stdout = String::from_utf8_lossy(&output.stdout);
1081 assert!(stdout.contains("[lean-ctx: output truncated"));
1082 }
1083
1084 #[cfg(unix)]
1090 #[test]
1091 fn wait_with_limits_group_kill_reaps_grandchildren() {
1092 use std::os::unix::process::CommandExt as _;
1093 let mut cmd = std::process::Command::new("sh");
1097 cmd.args(["-c", "sleep 30 & sleep 30"])
1098 .stdin(std::process::Stdio::null())
1099 .stdout(std::process::Stdio::piped())
1100 .stderr(std::process::Stdio::piped());
1101 cmd.process_group(0);
1102 let child = cmd.spawn().unwrap();
1103 let pgid = child.id() as libc::pid_t;
1104
1105 let start = std::time::Instant::now();
1106 let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1107 let elapsed = start.elapsed();
1108
1109 assert!(
1110 elapsed < std::time::Duration::from_secs(5),
1111 "group kill must unblock the reader threads, took {elapsed:?}"
1112 );
1113 let mut group_gone = false;
1116 for _ in 0..50 {
1117 if unsafe { libc::killpg(pgid, 0) } == -1 {
1119 group_gone = true;
1120 break;
1121 }
1122 std::thread::sleep(std::time::Duration::from_millis(20));
1123 }
1124 assert!(group_gone, "process group {pgid} must be fully reaped");
1125 }
1126
1127 #[test]
1128 fn heavy_commands_get_higher_byte_limits() {
1129 for cmd in [
1134 "cargo build --release",
1135 "cargo test --lib",
1136 "cargo nextest run",
1137 "npm run build",
1138 "docker build -t myapp .",
1139 "git commit --amend --no-edit",
1142 "git push -u origin HEAD",
1143 ] {
1144 let (bytes, _) = super::exec_limits(cmd);
1145 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1146 }
1147 }
1148
1149 #[test]
1150 fn normal_commands_get_default_byte_limits() {
1151 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1154 let (bytes, _) = super::exec_limits(cmd);
1155 assert_eq!(
1156 bytes,
1157 super::DEFAULT_MAX_BYTES,
1158 "default byte limit for {cmd}"
1159 );
1160 }
1161 }
1162
1163 #[test]
1164 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1165 let _lock = crate::core::data_dir::test_env_lock();
1167 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1168 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1169 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1170 for v in [
1171 "LEAN_CTX_SHELL_TIMEOUT_MS",
1172 "LEAN_CTX_SHELL_TIMEOUT_SECS",
1173 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1174 ] {
1175 crate::test_env::remove_var(v);
1176 }
1177
1178 assert_eq!(
1181 super::shell_timeout("cargo install --path ."),
1182 super::HEAVY_TIMEOUT
1183 );
1184 assert_eq!(
1185 super::shell_timeout("cargo nextest run"),
1186 super::HEAVY_TIMEOUT
1187 );
1188 assert_eq!(
1189 super::shell_timeout("git commit -m 'wip'"),
1190 super::HEAVY_TIMEOUT
1191 );
1192 assert_eq!(
1193 super::shell_timeout("git push origin main"),
1194 super::HEAVY_TIMEOUT
1195 );
1196 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1197 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1198
1199 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1202 assert_eq!(
1203 super::shell_timeout("cargo build"),
1204 std::time::Duration::from_secs(90)
1205 );
1206 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1207
1208 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1209 assert_eq!(
1210 super::shell_timeout("git status"),
1211 std::time::Duration::from_secs(30)
1212 );
1213 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1214
1215 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1217 assert_eq!(
1218 super::shell_timeout("cargo build"),
1219 std::time::Duration::from_secs(5)
1220 );
1221 assert_eq!(
1222 super::shell_timeout("git status"),
1223 std::time::Duration::from_secs(5)
1224 );
1225 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1226
1227 for (var, saved) in [
1228 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1229 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1230 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1231 ] {
1232 if let Some(v) = saved {
1233 crate::test_env::set_var(var, v);
1234 }
1235 }
1236 }
1237
1238 #[test]
1242 fn task_runners_get_heavy_ceiling() {
1243 let _lock = crate::core::data_dir::test_env_lock();
1244 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1245 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1246 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1247 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1248
1249 assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1250 assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1251 assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1252
1253 if let Some(v) = saved_ms {
1254 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1255 }
1256 if let Some(v) = saved_heavy {
1257 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1258 }
1259 }
1260
1261 #[test]
1265 fn per_call_timeout_override_resolves_and_clamps() {
1266 let _lock = crate::core::data_dir::test_env_lock();
1267 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1268 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1269
1270 assert_eq!(
1271 super::shell_timeout_with_override("git status", Some(300_000)),
1272 std::time::Duration::from_mins(5)
1273 );
1274 assert_eq!(
1275 super::shell_timeout_with_override("cargo build", Some(30_000)),
1276 std::time::Duration::from_secs(30)
1277 );
1278 assert_eq!(
1279 super::shell_timeout_with_override("git status", Some(999_000_000)),
1280 std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1281 );
1282 assert_eq!(
1283 super::shell_timeout_with_override("git status", Some(0)),
1284 super::DEFAULT_TIMEOUT
1285 );
1286 assert_eq!(
1287 super::shell_timeout_with_override("git status", None),
1288 super::DEFAULT_TIMEOUT
1289 );
1290
1291 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1292 assert_eq!(
1293 super::shell_timeout_with_override("git status", Some(300_000)),
1294 std::time::Duration::from_secs(5)
1295 );
1296 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1297 if let Some(v) = saved_ms {
1298 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1299 }
1300 }
1301
1302 #[test]
1304 fn allowlist_enforces_in_hook_child_mode() {
1305 assert!(super::allowlist_must_enforce_inner(true, false, true));
1307 assert!(super::allowlist_must_enforce_inner(true, true, true));
1308 }
1309
1310 #[test]
1311 fn allowlist_enforces_for_non_interactive_callers() {
1312 assert!(super::allowlist_must_enforce_inner(false, false, false));
1314 }
1315
1316 #[test]
1317 fn allowlist_warns_for_interactive_humans() {
1318 assert!(!super::allowlist_must_enforce_inner(false, false, true));
1320 }
1321
1322 #[test]
1323 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1324 assert!(!super::allowlist_must_enforce_inner(false, true, false));
1326 assert!(super::allowlist_must_enforce_inner(true, true, false));
1327 }
1328}