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
329 let matches_heavy = |s: &str| HEAVY_PREFIXES.iter().any(|p| s.starts_with(p));
330
331 if matches_heavy(&lower) {
332 return true;
333 }
334
335 let final_cmd = lower
338 .rsplit_once("&&")
339 .or_else(|| lower.rsplit_once(';'))
340 .map_or("", |(_, rhs)| rhs.trim());
341
342 !final_cmd.is_empty() && matches_heavy(final_cmd)
343}
344
345pub fn exec_argv(args: &[String]) -> i32 {
351 if args.is_empty() {
352 return 127;
353 }
354
355 let joined = super::platform::join_command(args);
360
361 if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
365 return exec(&u.rebuild());
366 }
367
368 if let Some(code) = allowlist_gate(&joined) {
374 return code;
375 }
376
377 if super::reentry::should_pass_through() {
378 return exec_direct(args);
379 }
380
381 let cfg = config::Config::load();
382 let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
383
384 if policy.is_protected() {
385 let code = exec_direct(args);
386 crate::core::tool_lifecycle::record_shell_command(0, 0);
387 return code;
388 }
389
390 let code = exec_direct(args);
391 crate::core::tool_lifecycle::record_shell_command(0, 0);
392 code
393}
394
395fn exec_direct(args: &[String]) -> i32 {
396 let mut cmd = Command::new(&args[0]);
397 cmd.args(&args[1..])
398 .stdin(Stdio::inherit())
399 .stdout(Stdio::inherit())
400 .stderr(Stdio::inherit());
401 super::reentry::mark_child(&mut cmd);
402 super::platform::apply_utf8_locale(&mut cmd);
403 let status = cmd.status();
404
405 match status {
406 Ok(s) => s.code().unwrap_or(1),
407 Err(e) => {
408 tracing::error!("lean-ctx: failed to execute: {e}");
409 127
410 }
411 }
412}
413
414fn allowlist_must_enforce() -> bool {
427 let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
428 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
429 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
430 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
431}
432
433fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
436 if hook_child {
437 return true;
438 }
439 if warn_only {
440 return false;
441 }
442 !stderr_is_tty
443}
444
445fn stdout_is_regular_file() -> bool {
458 #[cfg(unix)]
459 {
460 use std::os::unix::io::{AsRawFd, FromRawFd};
461 let fd = io::stdout().as_raw_fd();
462 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
465 file.metadata().is_ok_and(|m| m.is_file())
466 }
467 #[cfg(windows)]
468 {
469 use std::os::windows::io::{AsRawHandle, FromRawHandle};
470 let handle = io::stdout().as_raw_handle();
471 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
474 file.metadata().is_ok_and(|m| m.is_file())
475 }
476 #[cfg(not(any(unix, windows)))]
477 {
478 false
479 }
480}
481
482fn allowlist_gate(command: &str) -> Option<i32> {
490 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
491 if allowlist_must_enforce() {
492 eprintln!("{msg}");
493 eprintln!(
494 "lean-ctx: command blocked by shell allowlist. \
495 Allow it permanently: lean-ctx allow <cmd> — or set \
496 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
497 );
498 return Some(126);
499 }
500 if io::stderr().is_terminal() {
505 tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
506 } else {
507 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
508 }
509 }
510 None
511}
512
513pub fn exec(command: &str) -> i32 {
514 let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
520 let mut collapsed_nested = false;
521 let collapsed;
522 let command = unwrapped.as_deref().unwrap_or(command);
523 let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
524 collapsed_nested = true;
525 collapsed = c;
526 collapsed.as_str()
527 } else {
528 command
529 };
530
531 if let Some(code) = allowlist_gate(command) {
532 return code;
533 }
534
535 let (shell, shell_flag) = super::platform::shell_and_flag();
536 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
537 let command = command.as_str();
538
539 if super::reentry::is_disabled() {
540 return exec_inherit(command, &shell, &shell_flag);
541 }
542 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
543 return exec_shell_default(command, &shell, &shell_flag);
544 }
545
546 let cfg = config::Config::load();
547 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
548 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
549
550 if raw_mode {
551 return exec_inherit_tracked(command, &shell, &shell_flag);
552 }
553
554 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
555
556 if policy == super::output_policy::OutputPolicy::Passthrough {
558 return exec_inherit_tracked(command, &shell, &shell_flag);
559 }
560
561 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
565 return exec_inherit_tracked(command, &shell, &shell_flag);
566 }
567
568 if !force_compress {
569 if io::stdout().is_terminal() {
570 return exec_inherit_tracked(command, &shell, &shell_flag);
571 }
572 let code = exec_inherit(command, &shell, &shell_flag);
573 crate::core::tool_lifecycle::record_shell_command(0, 0);
574 return code;
575 }
576
577 if stdout_is_regular_file() {
586 return exec_inherit_tracked(command, &shell, &shell_flag);
587 }
588
589 exec_buffered(command, &shell, &shell_flag, &cfg)
590}
591
592fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
593 let mut current = command.trim().to_string();
594 let mut changed = false;
595
596 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
597 if next == current {
598 break;
599 }
600 current = next;
601 changed = true;
602 }
603
604 changed.then_some(current)
605}
606
607fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
608 super::reentry::is_wrapped() && !collapsed_nested
612}
613
614fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
615 let words = split_simple_shell_words(command)?;
616 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
617 return None;
618 }
619 if words[1].value != "-c" && words[1].value != "exec" {
620 return None;
621 }
622 if words[2..].iter().any(|w| {
623 matches!(
624 w.value.as_str(),
625 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
626 )
627 }) {
628 return None;
629 }
630 if words.len() == 3 {
631 Some(words[2].value.trim().to_string())
632 } else {
633 Some(command[words[2].start..].trim().to_string())
634 }
635}
636
637fn is_lean_ctx_bin(word: &str) -> bool {
638 std::path::Path::new(word)
639 .file_name()
640 .and_then(|name| name.to_str())
641 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
642}
643
644struct SimpleShellWord {
645 value: String,
646 start: usize,
647}
648
649fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
650 let mut words = Vec::new();
651 let mut current = String::new();
652 let mut current_start: Option<usize> = None;
653 let mut chars = command.char_indices().peekable();
654 let mut quote: Option<char> = None;
655
656 while let Some((idx, ch)) = chars.next() {
657 match quote {
658 Some('\'') if ch == '\'' => quote = None,
659 Some('"') if ch == '"' => quote = None,
660 None if ch == '\'' || ch == '"' => {
661 current_start.get_or_insert(idx);
662 quote = Some(ch);
663 }
664 Some('"') | None if ch == '\\' => {
665 current_start.get_or_insert(idx);
666 if let Some((_, next)) = chars.next() {
667 current.push(next);
668 }
669 }
670 None if ch.is_whitespace() => {
671 if let Some(start) = current_start.take() {
672 words.push(SimpleShellWord {
673 value: std::mem::take(&mut current),
674 start,
675 });
676 }
677 }
678 Some(_) | None => {
679 current_start.get_or_insert(idx);
680 current.push(ch);
681 }
682 }
683 }
684
685 if quote.is_some() {
686 return None;
687 }
688 if let Some(start) = current_start {
689 words.push(SimpleShellWord {
690 value: current,
691 start,
692 });
693 }
694 (!words.is_empty()).then_some(words)
695}
696
697fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
698 let mut cmd = Command::new(shell);
699 cmd.arg(shell_flag)
700 .arg(command)
701 .stdin(Stdio::inherit())
702 .stdout(Stdio::inherit())
703 .stderr(Stdio::inherit());
704 super::reentry::mark_child(&mut cmd);
705 super::platform::apply_utf8_locale(&mut cmd);
706 super::platform::apply_profile_free_env(&mut cmd);
707 let status = cmd.status();
708
709 match status {
710 Ok(s) => s.code().unwrap_or(1),
711 Err(e) => {
712 tracing::error!("lean-ctx: failed to execute: {e}");
713 127
714 }
715 }
716}
717
718fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
719 let mut cmd = Command::new(shell);
720 cmd.arg(shell_flag)
721 .arg(command)
722 .stdin(Stdio::inherit())
723 .stdout(Stdio::inherit())
724 .stderr(Stdio::inherit());
725 super::reentry::clear_shell_default_markers(&mut cmd);
726 super::platform::apply_utf8_locale(&mut cmd);
727 super::platform::apply_profile_free_env(&mut cmd);
728 let status = cmd.status();
729
730 match status {
731 Ok(s) => s.code().unwrap_or(1),
732 Err(e) => {
733 eprintln!("lean-ctx: failed to execute '{command}': {e}");
734 127
735 }
736 }
737}
738
739fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
740 let code = exec_inherit(command, shell, shell_flag);
741 crate::core::tool_lifecycle::record_shell_command(0, 0);
742 code
743}
744
745pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
749
750pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
754 match (stdout.is_empty(), stderr.is_empty()) {
755 (_, true) => stdout.to_string(),
756 (true, false) => stderr.to_string(),
757 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
758 (false, false) => format!("{stdout}\n{stderr}"),
759 }
760}
761
762fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
763 #[cfg(windows)]
764 super::platform::set_console_utf8();
765
766 let start = std::time::Instant::now();
767
768 let mut cmd = Command::new(shell);
769
770 #[cfg(windows)]
771 let ps_tmp_path: Option<tempfile::TempPath>;
772 #[cfg(windows)]
773 {
774 if super::platform::is_powershell(shell) {
775 let ps_script = format!(
776 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
777 command
778 );
779 match tempfile::Builder::new()
783 .prefix("lean-ctx-ps-")
784 .suffix(".ps1")
785 .tempfile()
786 {
787 Ok(tmp) => {
788 let tmp_path = tmp.into_temp_path();
789 let _ = std::fs::write(&tmp_path, &ps_script);
790 cmd.args([
791 "-NoProfile",
792 "-ExecutionPolicy",
793 "Bypass",
794 "-File",
795 &tmp_path.to_string_lossy(),
796 ]);
797 ps_tmp_path = Some(tmp_path);
798 }
799 Err(e) => {
800 tracing::warn!(
801 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
802 );
803 cmd.arg(shell_flag);
804 cmd.arg(command);
805 ps_tmp_path = None;
806 }
807 }
808 } else {
809 cmd.arg(shell_flag);
810 cmd.arg(command);
811 ps_tmp_path = None;
812 }
813 }
814 #[cfg(not(windows))]
815 {
816 cmd.arg(shell_flag);
817 cmd.arg(command);
818 }
819
820 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
821 let isolate = !io::stdin().is_terminal();
832 if isolate {
833 cmd.stdin(Stdio::null());
834 #[cfg(unix)]
835 {
836 use std::os::unix::process::CommandExt as _;
837 cmd.process_group(0);
838 }
839 }
840 super::reentry::mark_child(&mut cmd);
841 super::platform::apply_utf8_locale(&mut cmd);
842 super::platform::apply_profile_free_env(&mut cmd);
843 let child = cmd.spawn();
844
845 let child = match child {
846 Ok(c) => c,
847 Err(e) => {
848 tracing::error!("lean-ctx: failed to execute: {e}");
849 #[cfg(windows)]
850 if let Some(ref tmp) = ps_tmp_path {
851 let _ = std::fs::remove_file(tmp);
852 }
853 return 127;
854 }
855 };
856
857 let (max_bytes, timeout) = exec_limits(command);
858 let output = wait_with_limits(child, max_bytes, timeout, isolate);
859
860 let duration_ms = start.elapsed().as_millis();
861 let exit_code = output.status.code().unwrap_or(1);
862 let stdout = super::platform::decode_output(&output.stdout);
863 let stderr = super::platform::decode_output(&output.stderr);
864
865 let full_output = combine_streams(&stdout, &stderr, exit_code);
866 let input_tokens = count_tokens(&full_output);
867
868 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
871
872 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
875
876 let (compressed, output_tokens) =
877 super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
878
879 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
880
881 if !compressed.is_empty() {
882 let _ = io::stdout().write_all(compressed.as_bytes());
883 if !compressed.ends_with('\n') {
884 let _ = io::stdout().write_all(b"\n");
885 }
886 }
887 let should_tee = super::tee_policy::should_tee(
890 &cfg.tee_mode,
891 exit_code,
892 full_output.trim().is_empty(),
893 input_tokens,
894 output_tokens,
895 );
896 if should_tee
897 && let Some(path) = super::redact::save_tee(command, &full_output)
898 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
899 {
900 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
901 }
902
903 let threshold = cfg.slow_command_threshold_ms;
904 if threshold > 0 && duration_ms >= threshold as u128 {
905 slow_log::record(command, duration_ms, exit_code);
906 }
907
908 #[cfg(windows)]
909 if let Some(ref tmp) = ps_tmp_path {
910 let _ = std::fs::remove_file(tmp);
911 }
912
913 exit_code
914}
915
916#[cfg(test)]
917mod exec_tests {
918 #[test]
919 fn combine_streams_labels_stderr_on_failure() {
920 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
921 assert_eq!(
922 out,
923 format!(
924 "build ok\n{}\nlinker: undefined symbol",
925 super::STDERR_LABEL
926 )
927 );
928 }
929
930 #[test]
931 fn combine_streams_plain_join_on_success() {
932 let out = super::combine_streams("step 1", "warning: noop", 0);
933 assert_eq!(out, "step 1\nwarning: noop");
934 assert!(!out.contains(super::STDERR_LABEL));
935 }
936
937 #[test]
938 fn combine_streams_single_stream_is_unchanged() {
939 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
940 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
941 }
942
943 #[test]
944 fn exec_direct_runs_true() {
945 let code = super::exec_direct(&["true".to_string()]);
946 assert_eq!(code, 0);
947 }
948
949 #[test]
950 fn exec_direct_runs_false() {
951 let code = super::exec_direct(&["false".to_string()]);
952 assert_ne!(code, 0);
953 }
954
955 #[test]
956 fn exec_direct_preserves_args_with_special_chars() {
957 let code = super::exec_direct(&[
958 "echo".to_string(),
959 "hello world".to_string(),
960 "it's here".to_string(),
961 "a \"quoted\" thing".to_string(),
962 ]);
963 assert_eq!(code, 0);
964 }
965
966 #[test]
967 fn exec_direct_nonexistent_returns_127() {
968 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
969 assert_eq!(code, 127);
970 }
971
972 #[test]
973 fn exec_argv_empty_returns_127() {
974 let code = super::exec_argv(&[]);
975 assert_eq!(code, 127);
976 }
977
978 #[test]
979 fn exec_argv_runs_simple_command() {
980 let _lock = crate::core::data_dir::test_env_lock();
981 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
982 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
983 let code = super::exec_argv(&["true".to_string()]);
984 assert_eq!(code, 0);
985 }
986
987 #[test]
988 fn exec_argv_passes_through_when_disabled() {
989 let _lock = crate::core::data_dir::test_env_lock();
990 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
991 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
992 let code = super::exec_argv(&["true".to_string()]);
993 crate::test_env::remove_var("LEAN_CTX_DISABLED");
994 assert_eq!(code, 0);
995 }
996
997 #[test]
1001 fn exec_argv_enforces_allowlist_for_disallowed_command() {
1002 let _lock = crate::core::data_dir::test_env_lock();
1003 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1004 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1005 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
1006 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1008 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
1009
1010 let code = super::exec_argv(&["true".to_string()]);
1011
1012 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1013 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1014
1015 assert_eq!(
1016 code, 126,
1017 "non-allowlisted command must be blocked on the -t track path"
1018 );
1019 }
1020
1021 #[test]
1022 fn exec_argv_allows_allowlisted_command() {
1023 let _lock = crate::core::data_dir::test_env_lock();
1024 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1025 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1026 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1027 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1028
1029 let code = super::exec_argv(&["true".to_string()]);
1030
1031 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1032 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1033
1034 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1035 }
1036
1037 #[test]
1038 fn wait_with_limits_captures_output() {
1039 let child = std::process::Command::new("echo")
1040 .arg("hello")
1041 .stdout(std::process::Stdio::piped())
1042 .stderr(std::process::Stdio::piped())
1043 .spawn()
1044 .unwrap();
1045
1046 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1047 let stdout = String::from_utf8_lossy(&output.stdout);
1048 assert!(
1049 stdout.contains("hello"),
1050 "expected 'hello' in output: {stdout}"
1051 );
1052 assert!(output.status.success());
1053 }
1054
1055 #[test]
1056 fn wait_with_limits_truncates_large_output() {
1057 let child = std::process::Command::new("sh")
1059 .args(["-c", "yes 'aaaa' | head -25000"])
1060 .stdout(std::process::Stdio::piped())
1061 .stderr(std::process::Stdio::piped())
1062 .spawn()
1063 .unwrap();
1064
1065 let output =
1066 super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1067 let stdout = String::from_utf8_lossy(&output.stdout);
1068 assert!(
1069 stdout.contains("[lean-ctx: output truncated"),
1070 "expected truncation notice, got len={}: ...{}",
1071 stdout.len(),
1072 &stdout[stdout.len().saturating_sub(80)..]
1073 );
1074 }
1075
1076 #[test]
1077 fn wait_with_limits_timeout_kills_process() {
1078 let child = std::process::Command::new("sleep")
1079 .arg("60")
1080 .stdout(std::process::Stdio::piped())
1081 .stderr(std::process::Stdio::piped())
1082 .spawn()
1083 .unwrap();
1084
1085 let start = std::time::Instant::now();
1086 let output =
1087 super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1088 let elapsed = start.elapsed();
1089
1090 assert!(
1091 elapsed < std::time::Duration::from_secs(3),
1092 "timeout should kill quickly, took {elapsed:?}"
1093 );
1094 let stdout = String::from_utf8_lossy(&output.stdout);
1095 assert!(stdout.contains("[lean-ctx: output truncated"));
1096 }
1097
1098 #[cfg(unix)]
1104 #[test]
1105 fn wait_with_limits_group_kill_reaps_grandchildren() {
1106 use std::os::unix::process::CommandExt as _;
1107 let mut cmd = std::process::Command::new("sh");
1111 cmd.args(["-c", "sleep 30 & sleep 30"])
1112 .stdin(std::process::Stdio::null())
1113 .stdout(std::process::Stdio::piped())
1114 .stderr(std::process::Stdio::piped());
1115 cmd.process_group(0);
1116 let child = cmd.spawn().unwrap();
1117 let pgid = child.id() as libc::pid_t;
1118
1119 let start = std::time::Instant::now();
1120 let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1121 let elapsed = start.elapsed();
1122
1123 assert!(
1124 elapsed < std::time::Duration::from_secs(5),
1125 "group kill must unblock the reader threads, took {elapsed:?}"
1126 );
1127 let mut group_gone = false;
1130 for _ in 0..50 {
1131 if unsafe { libc::killpg(pgid, 0) } == -1 {
1133 group_gone = true;
1134 break;
1135 }
1136 std::thread::sleep(std::time::Duration::from_millis(20));
1137 }
1138 assert!(group_gone, "process group {pgid} must be fully reaped");
1139 }
1140
1141 #[test]
1142 fn heavy_commands_get_higher_byte_limits() {
1143 for cmd in [
1148 "cargo build --release",
1149 "cargo test --lib",
1150 "cargo nextest run",
1151 "npm run build",
1152 "docker build -t myapp .",
1153 "git commit --amend --no-edit",
1156 "git push -u origin HEAD",
1157 "cd /some/path && cargo test --lib",
1160 "cd /foo/bar && cargo build --release",
1161 "cd /workspace; npm ci",
1162 ] {
1163 let (bytes, _) = super::exec_limits(cmd);
1164 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1165 }
1166 }
1167
1168 #[test]
1169 fn normal_commands_get_default_byte_limits() {
1170 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1173 let (bytes, _) = super::exec_limits(cmd);
1174 assert_eq!(
1175 bytes,
1176 super::DEFAULT_MAX_BYTES,
1177 "default byte limit for {cmd}"
1178 );
1179 }
1180 }
1181
1182 #[test]
1183 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1184 let _lock = crate::core::data_dir::test_env_lock();
1186 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1187 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1188 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1189 for v in [
1190 "LEAN_CTX_SHELL_TIMEOUT_MS",
1191 "LEAN_CTX_SHELL_TIMEOUT_SECS",
1192 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1193 ] {
1194 crate::test_env::remove_var(v);
1195 }
1196
1197 assert_eq!(
1200 super::shell_timeout("cargo install --path ."),
1201 super::HEAVY_TIMEOUT
1202 );
1203 assert_eq!(
1204 super::shell_timeout("cargo nextest run"),
1205 super::HEAVY_TIMEOUT
1206 );
1207 assert_eq!(
1208 super::shell_timeout("git commit -m 'wip'"),
1209 super::HEAVY_TIMEOUT
1210 );
1211 assert_eq!(
1212 super::shell_timeout("git push origin main"),
1213 super::HEAVY_TIMEOUT
1214 );
1215 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1216 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1217 assert_eq!(
1219 super::shell_timeout("cd /some/project && cargo test --lib"),
1220 super::HEAVY_TIMEOUT
1221 );
1222 assert_eq!(
1223 super::shell_timeout("cd /workspace && cargo build --release"),
1224 super::HEAVY_TIMEOUT
1225 );
1226 assert_eq!(
1227 super::shell_timeout("cd /app; npm ci"),
1228 super::HEAVY_TIMEOUT
1229 );
1230
1231 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1234 assert_eq!(
1235 super::shell_timeout("cargo build"),
1236 std::time::Duration::from_secs(90)
1237 );
1238 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1239
1240 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1241 assert_eq!(
1242 super::shell_timeout("git status"),
1243 std::time::Duration::from_secs(30)
1244 );
1245 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1246
1247 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1249 assert_eq!(
1250 super::shell_timeout("cargo build"),
1251 std::time::Duration::from_secs(5)
1252 );
1253 assert_eq!(
1254 super::shell_timeout("git status"),
1255 std::time::Duration::from_secs(5)
1256 );
1257 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1258
1259 for (var, saved) in [
1260 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1261 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1262 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1263 ] {
1264 if let Some(v) = saved {
1265 crate::test_env::set_var(var, v);
1266 }
1267 }
1268 }
1269
1270 #[test]
1274 fn task_runners_get_heavy_ceiling() {
1275 let _lock = crate::core::data_dir::test_env_lock();
1276 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1277 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1278 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1279 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1280
1281 assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1282 assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1283 assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1284
1285 if let Some(v) = saved_ms {
1286 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1287 }
1288 if let Some(v) = saved_heavy {
1289 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1290 }
1291 }
1292
1293 #[test]
1297 fn per_call_timeout_override_resolves_and_clamps() {
1298 let _lock = crate::core::data_dir::test_env_lock();
1299 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1300 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1301
1302 assert_eq!(
1303 super::shell_timeout_with_override("git status", Some(300_000)),
1304 std::time::Duration::from_mins(5)
1305 );
1306 assert_eq!(
1307 super::shell_timeout_with_override("cargo build", Some(30_000)),
1308 std::time::Duration::from_secs(30)
1309 );
1310 assert_eq!(
1311 super::shell_timeout_with_override("git status", Some(999_000_000)),
1312 std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1313 );
1314 assert_eq!(
1315 super::shell_timeout_with_override("git status", Some(0)),
1316 super::DEFAULT_TIMEOUT
1317 );
1318 assert_eq!(
1319 super::shell_timeout_with_override("git status", None),
1320 super::DEFAULT_TIMEOUT
1321 );
1322
1323 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1324 assert_eq!(
1325 super::shell_timeout_with_override("git status", Some(300_000)),
1326 std::time::Duration::from_secs(5)
1327 );
1328 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1329 if let Some(v) = saved_ms {
1330 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1331 }
1332 }
1333
1334 #[test]
1336 fn allowlist_enforces_in_hook_child_mode() {
1337 assert!(super::allowlist_must_enforce_inner(true, false, true));
1339 assert!(super::allowlist_must_enforce_inner(true, true, true));
1340 }
1341
1342 #[test]
1343 fn allowlist_enforces_for_non_interactive_callers() {
1344 assert!(super::allowlist_must_enforce_inner(false, false, false));
1346 }
1347
1348 #[test]
1349 fn allowlist_warns_for_interactive_humans() {
1350 assert!(!super::allowlist_must_enforce_inner(false, false, true));
1352 }
1353
1354 #[test]
1355 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1356 assert!(!super::allowlist_must_enforce_inner(false, true, false));
1358 assert!(super::allowlist_must_enforce_inner(true, true, false));
1359 }
1360}