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::piped());
839 #[cfg(unix)]
840 {
841 use std::os::unix::process::CommandExt as _;
842 cmd.process_group(0);
843 }
844 }
845 super::reentry::mark_child(&mut cmd);
846 super::platform::apply_utf8_locale(&mut cmd);
847 super::platform::apply_profile_free_env(&mut cmd);
848 let child = cmd.spawn();
849
850 let mut child = match child {
851 Ok(c) => c,
852 Err(e) => {
853 tracing::error!("lean-ctx: failed to execute: {e}");
854 #[cfg(windows)]
855 if let Some(ref tmp) = ps_tmp_path {
856 let _ = std::fs::remove_file(tmp);
857 }
858 return 127;
859 }
860 };
861
862 if isolate && let Some(child_stdin) = child.stdin.take() {
868 std::thread::Builder::new()
869 .name("stdin-relay".into())
870 .spawn(move || {
871 use std::io::Write;
872 let mut child_w = child_stdin;
873 let mut parent_r = io::stdin().lock();
874 let mut buf = [0u8; 8192];
875 loop {
876 match parent_r.read(&mut buf) {
877 Ok(0) => break,
878 Ok(n) => {
879 if child_w.write_all(&buf[..n]).is_err() {
880 break;
881 }
882 }
883 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
884 Err(_) => break,
885 }
886 }
887 drop(child_w);
888 })
889 .ok();
890 }
891
892 let (max_bytes, timeout) = exec_limits(command);
893 let output = wait_with_limits(child, max_bytes, timeout, isolate);
894
895 let duration_ms = start.elapsed().as_millis();
896 let exit_code = output.status.code().unwrap_or(1);
897 let stdout = super::platform::decode_output(&output.stdout);
898 let stderr = super::platform::decode_output(&output.stderr);
899
900 let full_output = combine_streams(&stdout, &stderr, exit_code);
901 let input_tokens = count_tokens(&full_output);
902
903 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
906
907 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
910
911 let (compressed, output_tokens) =
912 super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
913
914 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
915
916 if !compressed.is_empty() {
917 let _ = io::stdout().write_all(compressed.as_bytes());
918 if !compressed.ends_with('\n') {
919 let _ = io::stdout().write_all(b"\n");
920 }
921 }
922 let should_tee = super::tee_policy::should_tee(
925 &cfg.tee_mode,
926 exit_code,
927 full_output.trim().is_empty(),
928 input_tokens,
929 output_tokens,
930 );
931 if should_tee
932 && let Some(path) = super::redact::save_tee(command, &full_output)
933 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
934 {
935 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
936 }
937
938 let threshold = cfg.slow_command_threshold_ms;
939 if threshold > 0 && duration_ms >= threshold as u128 {
940 slow_log::record(command, duration_ms, exit_code);
941 }
942
943 #[cfg(windows)]
944 if let Some(ref tmp) = ps_tmp_path {
945 let _ = std::fs::remove_file(tmp);
946 }
947
948 exit_code
949}
950
951#[cfg(test)]
952mod exec_tests {
953 #[test]
954 fn combine_streams_labels_stderr_on_failure() {
955 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
956 assert_eq!(
957 out,
958 format!(
959 "build ok\n{}\nlinker: undefined symbol",
960 super::STDERR_LABEL
961 )
962 );
963 }
964
965 #[test]
966 fn combine_streams_plain_join_on_success() {
967 let out = super::combine_streams("step 1", "warning: noop", 0);
968 assert_eq!(out, "step 1\nwarning: noop");
969 assert!(!out.contains(super::STDERR_LABEL));
970 }
971
972 #[test]
973 fn combine_streams_single_stream_is_unchanged() {
974 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
975 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
976 }
977
978 #[test]
979 fn exec_direct_runs_true() {
980 let code = super::exec_direct(&["true".to_string()]);
981 assert_eq!(code, 0);
982 }
983
984 #[test]
985 fn exec_direct_runs_false() {
986 let code = super::exec_direct(&["false".to_string()]);
987 assert_ne!(code, 0);
988 }
989
990 #[test]
991 fn exec_direct_preserves_args_with_special_chars() {
992 let code = super::exec_direct(&[
993 "echo".to_string(),
994 "hello world".to_string(),
995 "it's here".to_string(),
996 "a \"quoted\" thing".to_string(),
997 ]);
998 assert_eq!(code, 0);
999 }
1000
1001 #[test]
1002 fn exec_direct_nonexistent_returns_127() {
1003 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
1004 assert_eq!(code, 127);
1005 }
1006
1007 #[test]
1008 fn exec_argv_empty_returns_127() {
1009 let code = super::exec_argv(&[]);
1010 assert_eq!(code, 127);
1011 }
1012
1013 #[test]
1014 fn exec_argv_runs_simple_command() {
1015 let _lock = crate::core::data_dir::test_env_lock();
1016 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1017 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1018 let code = super::exec_argv(&["true".to_string()]);
1019 assert_eq!(code, 0);
1020 }
1021
1022 #[test]
1023 fn exec_argv_passes_through_when_disabled() {
1024 let _lock = crate::core::data_dir::test_env_lock();
1025 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1026 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
1027 let code = super::exec_argv(&["true".to_string()]);
1028 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1029 assert_eq!(code, 0);
1030 }
1031
1032 #[test]
1036 fn exec_argv_enforces_allowlist_for_disallowed_command() {
1037 let _lock = crate::core::data_dir::test_env_lock();
1038 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1039 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1040 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
1041 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1043 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
1044
1045 let code = super::exec_argv(&["true".to_string()]);
1046
1047 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1048 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1049
1050 assert_eq!(
1051 code, 126,
1052 "non-allowlisted command must be blocked on the -t track path"
1053 );
1054 }
1055
1056 #[test]
1057 fn exec_argv_allows_allowlisted_command() {
1058 let _lock = crate::core::data_dir::test_env_lock();
1059 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1060 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1061 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1062 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1063
1064 let code = super::exec_argv(&["true".to_string()]);
1065
1066 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1067 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1068
1069 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1070 }
1071
1072 #[test]
1073 fn wait_with_limits_captures_output() {
1074 let child = std::process::Command::new("echo")
1075 .arg("hello")
1076 .stdout(std::process::Stdio::piped())
1077 .stderr(std::process::Stdio::piped())
1078 .spawn()
1079 .unwrap();
1080
1081 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1082 let stdout = String::from_utf8_lossy(&output.stdout);
1083 assert!(
1084 stdout.contains("hello"),
1085 "expected 'hello' in output: {stdout}"
1086 );
1087 assert!(output.status.success());
1088 }
1089
1090 #[test]
1091 fn wait_with_limits_truncates_large_output() {
1092 let child = std::process::Command::new("sh")
1094 .args(["-c", "yes 'aaaa' | head -25000"])
1095 .stdout(std::process::Stdio::piped())
1096 .stderr(std::process::Stdio::piped())
1097 .spawn()
1098 .unwrap();
1099
1100 let output =
1101 super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1102 let stdout = String::from_utf8_lossy(&output.stdout);
1103 assert!(
1104 stdout.contains("[lean-ctx: output truncated"),
1105 "expected truncation notice, got len={}: ...{}",
1106 stdout.len(),
1107 &stdout[stdout.len().saturating_sub(80)..]
1108 );
1109 }
1110
1111 #[test]
1112 fn wait_with_limits_timeout_kills_process() {
1113 let child = std::process::Command::new("sleep")
1114 .arg("60")
1115 .stdout(std::process::Stdio::piped())
1116 .stderr(std::process::Stdio::piped())
1117 .spawn()
1118 .unwrap();
1119
1120 let start = std::time::Instant::now();
1121 let output =
1122 super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1123 let elapsed = start.elapsed();
1124
1125 assert!(
1126 elapsed < std::time::Duration::from_secs(3),
1127 "timeout should kill quickly, took {elapsed:?}"
1128 );
1129 let stdout = String::from_utf8_lossy(&output.stdout);
1130 assert!(stdout.contains("[lean-ctx: output truncated"));
1131 }
1132
1133 #[cfg(unix)]
1139 #[test]
1140 fn wait_with_limits_group_kill_reaps_grandchildren() {
1141 use std::os::unix::process::CommandExt as _;
1142 let mut cmd = std::process::Command::new("sh");
1146 cmd.args(["-c", "sleep 30 & sleep 30"])
1147 .stdin(std::process::Stdio::null())
1148 .stdout(std::process::Stdio::piped())
1149 .stderr(std::process::Stdio::piped());
1150 cmd.process_group(0);
1151 let child = cmd.spawn().unwrap();
1152 let pgid = child.id() as libc::pid_t;
1153
1154 let start = std::time::Instant::now();
1155 let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1156 let elapsed = start.elapsed();
1157
1158 assert!(
1159 elapsed < std::time::Duration::from_secs(5),
1160 "group kill must unblock the reader threads, took {elapsed:?}"
1161 );
1162 let mut group_gone = false;
1165 for _ in 0..50 {
1166 if unsafe { libc::killpg(pgid, 0) } == -1 {
1168 group_gone = true;
1169 break;
1170 }
1171 std::thread::sleep(std::time::Duration::from_millis(20));
1172 }
1173 assert!(group_gone, "process group {pgid} must be fully reaped");
1174 }
1175
1176 #[cfg(unix)]
1180 #[test]
1181 fn stdin_relay_forwards_piped_data() {
1182 use std::io::Write;
1183 use std::os::unix::process::CommandExt as _;
1184
1185 let mut cmd = std::process::Command::new("cat");
1186 cmd.stdin(std::process::Stdio::piped())
1187 .stdout(std::process::Stdio::piped())
1188 .stderr(std::process::Stdio::piped());
1189 cmd.process_group(0);
1190 let mut child = cmd.spawn().expect("failed to spawn cat");
1191
1192 let mut child_stdin = child.stdin.take().unwrap();
1193 std::thread::spawn(move || {
1194 child_stdin.write_all(b"hello from pipe\n").unwrap();
1195 drop(child_stdin);
1196 });
1197
1198 let output = child.wait_with_output().expect("wait failed");
1199 let stdout = String::from_utf8_lossy(&output.stdout);
1200 assert!(
1201 stdout.contains("hello from pipe"),
1202 "#806: piped stdin must reach the child, got: {stdout}"
1203 );
1204 }
1205
1206 #[cfg(unix)]
1209 #[test]
1210 fn stdin_relay_no_data_does_not_hang() {
1211 let start = std::time::Instant::now();
1212 let mut cmd = std::process::Command::new("sh");
1213 cmd.args(["-c", "echo ok"])
1214 .stdin(std::process::Stdio::piped())
1215 .stdout(std::process::Stdio::piped())
1216 .stderr(std::process::Stdio::piped());
1217 use std::os::unix::process::CommandExt as _;
1218 cmd.process_group(0);
1219 let mut child = cmd.spawn().unwrap();
1220 drop(child.stdin.take());
1222 let output = child.wait_with_output().unwrap();
1223 let elapsed = start.elapsed();
1224 assert!(
1225 elapsed < std::time::Duration::from_secs(5),
1226 "must not hang when stdin is closed immediately, took {elapsed:?}"
1227 );
1228 let stdout = String::from_utf8_lossy(&output.stdout);
1229 assert!(stdout.contains("ok"), "command output missing: {stdout}");
1230 }
1231
1232 #[test]
1233 fn heavy_commands_get_higher_byte_limits() {
1234 for cmd in [
1239 "cargo build --release",
1240 "cargo test --lib",
1241 "cargo nextest run",
1242 "npm run build",
1243 "docker build -t myapp .",
1244 "git commit --amend --no-edit",
1247 "git push -u origin HEAD",
1248 "cd /some/path && cargo test --lib",
1251 "cd /foo/bar && cargo build --release",
1252 "cd /workspace; npm ci",
1253 ] {
1254 let (bytes, _) = super::exec_limits(cmd);
1255 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1256 }
1257 }
1258
1259 #[test]
1260 fn normal_commands_get_default_byte_limits() {
1261 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1264 let (bytes, _) = super::exec_limits(cmd);
1265 assert_eq!(
1266 bytes,
1267 super::DEFAULT_MAX_BYTES,
1268 "default byte limit for {cmd}"
1269 );
1270 }
1271 }
1272
1273 #[test]
1274 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1275 let _lock = crate::core::data_dir::test_env_lock();
1277 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1278 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1279 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1280 for v in [
1281 "LEAN_CTX_SHELL_TIMEOUT_MS",
1282 "LEAN_CTX_SHELL_TIMEOUT_SECS",
1283 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1284 ] {
1285 crate::test_env::remove_var(v);
1286 }
1287
1288 assert_eq!(
1291 super::shell_timeout("cargo install --path ."),
1292 super::HEAVY_TIMEOUT
1293 );
1294 assert_eq!(
1295 super::shell_timeout("cargo nextest run"),
1296 super::HEAVY_TIMEOUT
1297 );
1298 assert_eq!(
1299 super::shell_timeout("git commit -m 'wip'"),
1300 super::HEAVY_TIMEOUT
1301 );
1302 assert_eq!(
1303 super::shell_timeout("git push origin main"),
1304 super::HEAVY_TIMEOUT
1305 );
1306 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1307 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1308 assert_eq!(
1310 super::shell_timeout("cd /some/project && cargo test --lib"),
1311 super::HEAVY_TIMEOUT
1312 );
1313 assert_eq!(
1314 super::shell_timeout("cd /workspace && cargo build --release"),
1315 super::HEAVY_TIMEOUT
1316 );
1317 assert_eq!(
1318 super::shell_timeout("cd /app; npm ci"),
1319 super::HEAVY_TIMEOUT
1320 );
1321
1322 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1325 assert_eq!(
1326 super::shell_timeout("cargo build"),
1327 std::time::Duration::from_secs(90)
1328 );
1329 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1330
1331 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1332 assert_eq!(
1333 super::shell_timeout("git status"),
1334 std::time::Duration::from_secs(30)
1335 );
1336 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1337
1338 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1340 assert_eq!(
1341 super::shell_timeout("cargo build"),
1342 std::time::Duration::from_secs(5)
1343 );
1344 assert_eq!(
1345 super::shell_timeout("git status"),
1346 std::time::Duration::from_secs(5)
1347 );
1348 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1349
1350 for (var, saved) in [
1351 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1352 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1353 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1354 ] {
1355 if let Some(v) = saved {
1356 crate::test_env::set_var(var, v);
1357 }
1358 }
1359 }
1360
1361 #[test]
1365 fn task_runners_get_heavy_ceiling() {
1366 let _lock = crate::core::data_dir::test_env_lock();
1367 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1368 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1369 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1370 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1371
1372 assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1373 assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1374 assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1375
1376 if let Some(v) = saved_ms {
1377 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1378 }
1379 if let Some(v) = saved_heavy {
1380 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1381 }
1382 }
1383
1384 #[test]
1388 fn per_call_timeout_override_resolves_and_clamps() {
1389 let _lock = crate::core::data_dir::test_env_lock();
1390 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1391 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1392
1393 assert_eq!(
1394 super::shell_timeout_with_override("git status", Some(300_000)),
1395 std::time::Duration::from_mins(5)
1396 );
1397 assert_eq!(
1398 super::shell_timeout_with_override("cargo build", Some(30_000)),
1399 std::time::Duration::from_secs(30)
1400 );
1401 assert_eq!(
1402 super::shell_timeout_with_override("git status", Some(999_000_000)),
1403 std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1404 );
1405 assert_eq!(
1406 super::shell_timeout_with_override("git status", Some(0)),
1407 super::DEFAULT_TIMEOUT
1408 );
1409 assert_eq!(
1410 super::shell_timeout_with_override("git status", None),
1411 super::DEFAULT_TIMEOUT
1412 );
1413
1414 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1415 assert_eq!(
1416 super::shell_timeout_with_override("git status", Some(300_000)),
1417 std::time::Duration::from_secs(5)
1418 );
1419 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1420 if let Some(v) = saved_ms {
1421 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1422 }
1423 }
1424
1425 #[test]
1427 fn allowlist_enforces_in_hook_child_mode() {
1428 assert!(super::allowlist_must_enforce_inner(true, false, true));
1430 assert!(super::allowlist_must_enforce_inner(true, true, true));
1431 }
1432
1433 #[test]
1434 fn allowlist_enforces_for_non_interactive_callers() {
1435 assert!(super::allowlist_must_enforce_inner(false, false, false));
1437 }
1438
1439 #[test]
1440 fn allowlist_warns_for_interactive_humans() {
1441 assert!(!super::allowlist_must_enforce_inner(false, false, true));
1443 }
1444
1445 #[test]
1446 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1447 assert!(!super::allowlist_must_enforce_inner(false, true, false));
1449 assert!(super::allowlist_must_enforce_inner(true, true, false));
1450 }
1451}