1use std::io::{self, IsTerminal, Read, Write};
2use std::process::{Child, Command, Output, Stdio};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use crate::core::config;
7use crate::core::slow_log;
8use crate::core::tokens::count_tokens;
9
10fn wait_with_limits(
23 mut child: Child,
24 max_bytes: usize,
25 timeout: std::time::Duration,
26 kill_group: bool,
27) -> Output {
28 const STDERR_LIMIT: usize = 512 * 1024;
29
30 let stdout_pipe = child.stdout.take();
31 let stderr_pipe = child.stderr.take();
32 let start = std::time::Instant::now();
33 let truncated = Arc::new(AtomicBool::new(false));
34
35 let stdout_truncated_flag = Arc::clone(&truncated);
36 let stdout_handle = std::thread::spawn(move || {
37 let Some(mut pipe) = stdout_pipe else {
38 return (Vec::new(), false);
39 };
40 let mut buf = Vec::with_capacity(max_bytes.min(64 * 1024));
41 let mut chunk = [0u8; 8192];
42 loop {
43 match pipe.read(&mut chunk) {
44 Ok(0) => break,
45 Ok(n) => {
46 if buf.len() + n > max_bytes {
47 let remaining = max_bytes.saturating_sub(buf.len());
48 buf.extend_from_slice(&chunk[..remaining]);
49 stdout_truncated_flag.store(true, Ordering::Relaxed);
50 return (buf, true);
51 }
52 buf.extend_from_slice(&chunk[..n]);
53 }
54 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
55 Err(_) => break,
56 }
57 }
58 (buf, false)
59 });
60
61 let stderr_truncated_flag = Arc::clone(&truncated);
62 let stderr_handle = std::thread::spawn(move || {
63 let Some(mut pipe) = stderr_pipe else {
64 return (Vec::new(), false);
65 };
66 let mut buf = Vec::new();
67 let mut chunk = [0u8; 4096];
68 loop {
69 match pipe.read(&mut chunk) {
70 Ok(0) => break,
71 Ok(n) => {
72 if buf.len() + n > STDERR_LIMIT {
73 let remaining = STDERR_LIMIT.saturating_sub(buf.len());
74 buf.extend_from_slice(&chunk[..remaining]);
75 stderr_truncated_flag.store(true, Ordering::Relaxed);
76 return (buf, true);
77 }
78 buf.extend_from_slice(&chunk[..n]);
79 }
80 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
81 Err(_) => break,
82 }
83 }
84 (buf, false)
85 });
86
87 let mut timed_out = false;
88 loop {
89 let hit_timeout = start.elapsed() > timeout;
90 if hit_timeout || truncated.load(Ordering::Relaxed) {
91 kill_child(&mut child, kill_group);
92 let _ = child.wait();
93 timed_out = hit_timeout;
94 break;
95 }
96 match child.try_wait() {
97 Ok(Some(_)) | Err(_) => break,
98 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
99 }
100 }
101
102 let (mut stdout_buf, stdout_truncated) = stdout_handle.join().unwrap_or_default();
103 let (mut stderr_buf, stderr_truncated) = stderr_handle.join().unwrap_or_default();
104
105 if timed_out || stdout_truncated {
106 let notice = format!(
107 "\n[lean-ctx: output truncated at {} MB / {}s limit]\n",
108 max_bytes / (1024 * 1024),
109 timeout.as_secs()
110 );
111 stdout_buf.extend_from_slice(notice.as_bytes());
112 }
113 if stderr_truncated {
114 let notice = format!(
115 "\n[lean-ctx: stderr truncated at {} KB limit]\n",
116 STDERR_LIMIT / 1024
117 );
118 stderr_buf.extend_from_slice(notice.as_bytes());
119 }
120
121 let status = child.wait().unwrap_or_else(|_| synthetic_failure_status());
122
123 Output {
124 status,
125 stdout: stdout_buf,
126 stderr: stderr_buf,
127 }
128}
129
130fn kill_child(child: &mut Child, kill_group: bool) {
134 #[cfg(unix)]
135 if kill_group {
136 let pgid = child.id() as libc::pid_t;
137 if pgid > 0 {
138 unsafe { libc::killpg(pgid, libc::SIGKILL) };
140 }
141 }
142 #[cfg(not(unix))]
143 let _ = kill_group;
144 let _ = child.kill();
145}
146
147#[cfg(unix)]
155fn synthetic_failure_status() -> std::process::ExitStatus {
156 use std::os::unix::process::ExitStatusExt;
157 std::process::ExitStatus::from_raw(1 << 8)
161}
162
163#[cfg(not(unix))]
164fn synthetic_failure_status() -> std::process::ExitStatus {
165 use std::os::windows::process::ExitStatusExt;
166 std::process::ExitStatus::from_raw(1)
167}
168
169#[cfg(test)]
170mod nested_lean_ctx_exec_tests {
171 #[test]
172 fn collapses_single_nested_c() {
173 assert_eq!(
174 super::collapse_nested_lean_ctx_exec("lean-ctx -c 'git status'").as_deref(),
175 Some("git status")
176 );
177 }
178
179 #[test]
180 fn collapses_repeated_nested_c() {
181 assert_eq!(
182 super::collapse_nested_lean_ctx_exec("lean-ctx -c 'lean-ctx -c \"git status\"'")
183 .as_deref(),
184 Some("git status")
185 );
186 }
187
188 #[test]
189 fn preserves_inner_shell_quoting() {
190 assert_eq!(
191 super::collapse_nested_lean_ctx_exec("lean-ctx -c \"git commit -m 'hello world'\"")
192 .as_deref(),
193 Some("git commit -m 'hello world'")
194 );
195 assert_eq!(
196 super::collapse_nested_lean_ctx_exec("lean-ctx -c git commit -m 'hello world'")
197 .as_deref(),
198 Some("git commit -m 'hello world'")
199 );
200 }
201
202 #[test]
203 fn collapses_exec_alias_and_path() {
204 assert_eq!(
205 super::collapse_nested_lean_ctx_exec("/usr/local/bin/lean-ctx exec 'git status'")
206 .as_deref(),
207 Some("git status")
208 );
209 }
210
211 #[test]
212 fn leaves_non_wrappers_alone() {
213 assert!(super::collapse_nested_lean_ctx_exec("git status").is_none());
214 }
215
216 #[test]
217 fn wrapped_nested_wrapper_still_owns_one_compression_pass() {
218 let _lock = crate::core::data_dir::test_env_lock();
219 crate::test_env::set_var(super::super::reentry::WRAP_MARKER, "1");
220
221 assert!(super::should_delegate_wrapped_to_shell_default(false));
222 assert!(
223 !super::should_delegate_wrapped_to_shell_default(true),
224 "collapsed nested wrappers must not fall through to raw shell-default path"
225 );
226
227 crate::test_env::remove_var(super::super::reentry::WRAP_MARKER);
228 }
229}
230
231const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
233const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
235
236fn exec_limits(command: &str) -> (usize, std::time::Duration) {
237 let max_bytes = if is_heavy_command(command) {
238 HEAVY_MAX_BYTES
239 } else {
240 DEFAULT_MAX_BYTES
241 };
242 (max_bytes, shell_timeout(command))
243}
244
245#[must_use]
258pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
259 shell_timeout_with_override(command, None)
260}
261
262const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000; #[must_use]
272pub(crate) fn shell_timeout_with_override(
273 command: &str,
274 override_ms: Option<u64>,
275) -> std::time::Duration {
276 if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
277 return std::time::Duration::from_millis(ms);
278 }
279 if let Some(ms) = override_ms.filter(|n| *n > 0) {
280 return std::time::Duration::from_millis(ms.min(MAX_CALL_TIMEOUT_MS));
281 }
282 if is_heavy_command(command) {
283 if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
284 .or_else(|| config::Config::load().shell_heavy_timeout_secs)
285 {
286 return std::time::Duration::from_secs(secs);
287 }
288 HEAVY_TIMEOUT
289 } else {
290 if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
291 .or_else(|| config::Config::load().shell_timeout_secs)
292 {
293 return std::time::Duration::from_secs(secs);
294 }
295 DEFAULT_TIMEOUT
296 }
297}
298
299fn env_u64(var: &str) -> Option<u64> {
302 std::env::var(var)
303 .ok()
304 .and_then(|v| v.parse::<u64>().ok())
305 .filter(|n| *n > 0)
306}
307
308fn is_heavy_command(command: &str) -> bool {
309 let cmd = command.trim();
310 let lower = cmd.to_lowercase();
311 static HEAVY_PREFIXES: &[&str] = &[
312 "cargo build",
313 "cargo test",
314 "cargo nextest",
315 "cargo clippy",
316 "cargo check",
317 "cargo install",
318 "cargo bench",
319 "npm run build",
320 "npm install",
321 "npm ci",
322 "pnpm install",
323 "pnpm build",
324 "yarn install",
325 "yarn build",
326 "bun install",
327 "make",
328 "cmake",
329 "bazel build",
330 "bazel test",
331 "gradle build",
332 "gradle test",
333 "mvn package",
334 "mvn install",
335 "mvn test",
336 "go build",
337 "go test",
338 "dotnet build",
339 "dotnet test",
340 "swift build",
341 "swift test",
342 "flutter build",
343 "docker build",
344 "docker compose build",
345 "pip install",
346 "poetry install",
347 "uv sync",
348 "bundle install",
349 "mix compile",
350 "git commit",
357 "git push",
358 "mise ",
362 "just ",
363 ];
364
365 let matches_heavy = |s: &str| HEAVY_PREFIXES.iter().any(|p| s.starts_with(p));
366
367 if matches_heavy(&lower) {
368 return true;
369 }
370
371 let final_cmd = lower
374 .rsplit_once("&&")
375 .or_else(|| lower.rsplit_once(';'))
376 .map_or("", |(_, rhs)| rhs.trim());
377
378 !final_cmd.is_empty() && matches_heavy(final_cmd)
379}
380
381pub fn exec_argv(args: &[String]) -> i32 {
387 if args.is_empty() {
388 return 127;
389 }
390
391 let joined = super::platform::join_command(args);
396
397 if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
401 return exec(&u.rebuild());
402 }
403
404 if let Some(code) = allowlist_gate(&joined) {
410 return code;
411 }
412
413 if super::reentry::should_pass_through() {
414 return exec_direct(args);
415 }
416
417 let cfg = config::Config::load();
418 let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
419
420 if policy.is_protected() {
421 let code = exec_direct(args);
422 crate::core::tool_lifecycle::record_shell_command(0, 0);
423 return code;
424 }
425
426 let code = exec_direct(args);
427 crate::core::tool_lifecycle::record_shell_command(0, 0);
428 code
429}
430
431fn exec_direct(args: &[String]) -> i32 {
432 let mut cmd = Command::new(&args[0]);
433 cmd.args(&args[1..])
434 .stdin(Stdio::inherit())
435 .stdout(Stdio::inherit())
436 .stderr(Stdio::inherit());
437 super::reentry::mark_child(&mut cmd);
438 super::platform::apply_utf8_locale(&mut cmd);
439 let status = cmd.status();
440
441 match status {
442 Ok(s) => s.code().unwrap_or(1),
443 Err(e) => {
444 tracing::error!("lean-ctx: failed to execute: {e}");
445 127
446 }
447 }
448}
449
450fn allowlist_must_enforce() -> bool {
463 let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
464 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
465 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
466 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
467}
468
469fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
472 if hook_child {
473 return true;
474 }
475 if warn_only {
476 return false;
477 }
478 !stderr_is_tty
479}
480
481fn stdout_is_regular_file() -> bool {
494 #[cfg(unix)]
495 {
496 use std::os::unix::io::{AsRawFd, FromRawFd};
497 let fd = io::stdout().as_raw_fd();
498 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
501 file.metadata().is_ok_and(|m| m.is_file())
502 }
503 #[cfg(windows)]
504 {
505 use std::os::windows::io::{AsRawHandle, FromRawHandle};
506 let handle = io::stdout().as_raw_handle();
507 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
510 file.metadata().is_ok_and(|m| m.is_file())
511 }
512 #[cfg(not(any(unix, windows)))]
513 {
514 false
515 }
516}
517
518fn allowlist_gate(command: &str) -> Option<i32> {
526 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
527 if allowlist_must_enforce() {
528 eprintln!("{msg}");
529 eprintln!(
530 "lean-ctx: command blocked by shell allowlist. \
531 Allow it permanently: lean-ctx allow <cmd> — or set \
532 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
533 );
534 return Some(126);
535 }
536 if io::stderr().is_terminal() {
541 tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
542 } else {
543 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
544 }
545 }
546 None
547}
548
549pub fn exec(command: &str) -> i32 {
550 let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
556 let mut collapsed_nested = false;
557 let collapsed;
558 let command = unwrapped.as_deref().unwrap_or(command);
559 let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
560 collapsed_nested = true;
561 collapsed = c;
562 collapsed.as_str()
563 } else {
564 command
565 };
566
567 if let Some(code) = allowlist_gate(command) {
568 return code;
569 }
570
571 let (shell, shell_flag) = super::platform::shell_and_flag();
572 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
573 let command = command.as_str();
574
575 if super::reentry::is_disabled() {
576 return exec_inherit(command, &shell, &shell_flag);
577 }
578 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
579 return exec_shell_default(command, &shell, &shell_flag);
580 }
581
582 let cfg = config::Config::load();
583 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
584 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
585
586 if raw_mode {
587 return exec_inherit_tracked(command, &shell, &shell_flag);
588 }
589
590 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
591
592 if policy == super::output_policy::OutputPolicy::Passthrough {
594 return exec_inherit_tracked(command, &shell, &shell_flag);
595 }
596
597 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
601 return exec_inherit_tracked(command, &shell, &shell_flag);
602 }
603
604 if !force_compress {
605 if io::stdout().is_terminal() {
606 return exec_inherit_tracked(command, &shell, &shell_flag);
607 }
608 let code = exec_inherit(command, &shell, &shell_flag);
609 crate::core::tool_lifecycle::record_shell_command(0, 0);
610 return code;
611 }
612
613 if stdout_is_regular_file() {
622 return exec_inherit_tracked(command, &shell, &shell_flag);
623 }
624
625 exec_buffered(command, &shell, &shell_flag, &cfg)
626}
627
628fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
629 let mut current = command.trim().to_string();
630 let mut changed = false;
631
632 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
633 if next == current {
634 break;
635 }
636 current = next;
637 changed = true;
638 }
639
640 changed.then_some(current)
641}
642
643fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
644 super::reentry::is_wrapped() && !collapsed_nested
648}
649
650fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
651 let words = split_simple_shell_words(command)?;
652 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
653 return None;
654 }
655 if words[1].value != "-c" && words[1].value != "exec" {
656 return None;
657 }
658 if words[2..].iter().any(|w| {
659 matches!(
660 w.value.as_str(),
661 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
662 )
663 }) {
664 return None;
665 }
666 if words.len() == 3 {
667 Some(words[2].value.trim().to_string())
668 } else {
669 Some(command[words[2].start..].trim().to_string())
670 }
671}
672
673fn is_lean_ctx_bin(word: &str) -> bool {
674 std::path::Path::new(word)
675 .file_name()
676 .and_then(|name| name.to_str())
677 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
678}
679
680struct SimpleShellWord {
681 value: String,
682 start: usize,
683}
684
685fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
686 let mut words = Vec::new();
687 let mut current = String::new();
688 let mut current_start: Option<usize> = None;
689 let mut chars = command.char_indices().peekable();
690 let mut quote: Option<char> = None;
691
692 while let Some((idx, ch)) = chars.next() {
693 match quote {
694 Some('\'') if ch == '\'' => quote = None,
695 Some('"') if ch == '"' => quote = None,
696 None if ch == '\'' || ch == '"' => {
697 current_start.get_or_insert(idx);
698 quote = Some(ch);
699 }
700 Some('"') | None if ch == '\\' => {
701 current_start.get_or_insert(idx);
702 if let Some((_, next)) = chars.next() {
703 current.push(next);
704 }
705 }
706 None if ch.is_whitespace() => {
707 if let Some(start) = current_start.take() {
708 words.push(SimpleShellWord {
709 value: std::mem::take(&mut current),
710 start,
711 });
712 }
713 }
714 Some(_) | None => {
715 current_start.get_or_insert(idx);
716 current.push(ch);
717 }
718 }
719 }
720
721 if quote.is_some() {
722 return None;
723 }
724 if let Some(start) = current_start {
725 words.push(SimpleShellWord {
726 value: current,
727 start,
728 });
729 }
730 (!words.is_empty()).then_some(words)
731}
732
733fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
734 let mut cmd = Command::new(shell);
735 cmd.arg(shell_flag)
736 .arg(command)
737 .stdin(Stdio::inherit())
738 .stdout(Stdio::inherit())
739 .stderr(Stdio::inherit());
740 super::reentry::mark_child(&mut cmd);
741 super::platform::apply_utf8_locale(&mut cmd);
742 super::platform::apply_profile_free_env(&mut cmd);
743 let status = cmd.status();
744
745 match status {
746 Ok(s) => s.code().unwrap_or(1),
747 Err(e) => {
748 tracing::error!("lean-ctx: failed to execute: {e}");
749 127
750 }
751 }
752}
753
754fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
755 let mut cmd = Command::new(shell);
756 cmd.arg(shell_flag)
757 .arg(command)
758 .stdin(Stdio::inherit())
759 .stdout(Stdio::inherit())
760 .stderr(Stdio::inherit());
761 super::reentry::clear_shell_default_markers(&mut cmd);
762 super::platform::apply_utf8_locale(&mut cmd);
763 super::platform::apply_profile_free_env(&mut cmd);
764 let status = cmd.status();
765
766 match status {
767 Ok(s) => s.code().unwrap_or(1),
768 Err(e) => {
769 eprintln!("lean-ctx: failed to execute '{command}': {e}");
770 127
771 }
772 }
773}
774
775fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
776 let code = exec_inherit(command, shell, shell_flag);
777 crate::core::tool_lifecycle::record_shell_command(0, 0);
778 code
779}
780
781pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
785
786pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
790 match (stdout.is_empty(), stderr.is_empty()) {
791 (_, true) => stdout.to_string(),
792 (true, false) => stderr.to_string(),
793 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
794 (false, false) => format!("{stdout}\n{stderr}"),
795 }
796}
797
798fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
799 #[cfg(windows)]
800 super::platform::set_console_utf8();
801
802 let start = std::time::Instant::now();
803
804 let mut cmd = Command::new(shell);
805
806 #[cfg(windows)]
807 let ps_tmp_path: Option<tempfile::TempPath>;
808 #[cfg(windows)]
809 {
810 if super::platform::is_powershell(shell) {
811 let ps_script = format!(
812 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
813 command
814 );
815 match tempfile::Builder::new()
819 .prefix("lean-ctx-ps-")
820 .suffix(".ps1")
821 .tempfile()
822 {
823 Ok(tmp) => {
824 let tmp_path = tmp.into_temp_path();
825 let _ = std::fs::write(&tmp_path, &ps_script);
826 cmd.args([
827 "-NoProfile",
828 "-ExecutionPolicy",
829 "Bypass",
830 "-File",
831 &tmp_path.to_string_lossy(),
832 ]);
833 ps_tmp_path = Some(tmp_path);
834 }
835 Err(e) => {
836 tracing::warn!(
837 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
838 );
839 cmd.arg(shell_flag);
840 cmd.arg(command);
841 ps_tmp_path = None;
842 }
843 }
844 } else {
845 cmd.arg(shell_flag);
846 cmd.arg(command);
847 ps_tmp_path = None;
848 }
849 }
850 #[cfg(not(windows))]
851 {
852 cmd.arg(shell_flag);
853 cmd.arg(command);
854 }
855
856 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
857 let isolate = !io::stdin().is_terminal();
868 if isolate {
869 cmd.stdin(Stdio::piped());
875 #[cfg(unix)]
876 {
877 use std::os::unix::process::CommandExt as _;
878 cmd.process_group(0);
879 }
880 }
881 super::reentry::mark_child(&mut cmd);
882 super::platform::apply_utf8_locale(&mut cmd);
883 super::platform::apply_profile_free_env(&mut cmd);
884 let child = cmd.spawn();
885
886 let mut child = match child {
887 Ok(c) => c,
888 Err(e) => {
889 tracing::error!("lean-ctx: failed to execute: {e}");
890 #[cfg(windows)]
891 if let Some(ref tmp) = ps_tmp_path {
892 let _ = std::fs::remove_file(tmp);
893 }
894 return 127;
895 }
896 };
897
898 if isolate && let Some(child_stdin) = child.stdin.take() {
904 std::thread::Builder::new()
905 .name("stdin-relay".into())
906 .spawn(move || {
907 use std::io::Write;
908 let mut child_w = child_stdin;
909 let mut parent_r = io::stdin().lock();
910 let mut buf = [0u8; 8192];
911 loop {
912 match parent_r.read(&mut buf) {
913 Ok(0) => break,
914 Ok(n) => {
915 if child_w.write_all(&buf[..n]).is_err() {
916 break;
917 }
918 }
919 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
920 Err(_) => break,
921 }
922 }
923 drop(child_w);
924 })
925 .ok();
926 }
927
928 let (max_bytes, timeout) = exec_limits(command);
929 let output = wait_with_limits(child, max_bytes, timeout, isolate);
930
931 let duration_ms = start.elapsed().as_millis();
932 let exit_code = output.status.code().unwrap_or(1);
933 let stdout = super::platform::decode_output(&output.stdout);
934 let stderr = super::platform::decode_output(&output.stderr);
935
936 let full_output = combine_streams(&stdout, &stderr, exit_code);
937 let input_tokens = count_tokens(&full_output);
938
939 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
942
943 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
946
947 let (compressed, output_tokens) =
948 super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
949
950 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
951
952 if !compressed.is_empty() {
953 let _ = io::stdout().write_all(compressed.as_bytes());
954 if !compressed.ends_with('\n') {
955 let _ = io::stdout().write_all(b"\n");
956 }
957 }
958 let should_tee = super::tee_policy::should_tee(
961 &cfg.tee_mode,
962 exit_code,
963 full_output.trim().is_empty(),
964 super::tee_policy::output_was_elided(&full_output, &compressed),
965 input_tokens,
966 output_tokens,
967 );
968 if should_tee
969 && let Some(path) = super::redact::save_tee(command, &full_output)
970 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
971 {
972 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
973 }
974
975 let threshold = cfg.slow_command_threshold_ms;
976 if threshold > 0 && duration_ms >= threshold as u128 {
977 slow_log::record(command, duration_ms, exit_code);
978 }
979
980 #[cfg(windows)]
981 if let Some(ref tmp) = ps_tmp_path {
982 let _ = std::fs::remove_file(tmp);
983 }
984
985 exit_code
986}
987
988#[cfg(test)]
989mod exec_tests {
990 #[test]
991 fn combine_streams_labels_stderr_on_failure() {
992 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
993 assert_eq!(
994 out,
995 format!(
996 "build ok\n{}\nlinker: undefined symbol",
997 super::STDERR_LABEL
998 )
999 );
1000 }
1001
1002 #[test]
1003 fn combine_streams_plain_join_on_success() {
1004 let out = super::combine_streams("step 1", "warning: noop", 0);
1005 assert_eq!(out, "step 1\nwarning: noop");
1006 assert!(!out.contains(super::STDERR_LABEL));
1007 }
1008
1009 #[test]
1010 fn combine_streams_single_stream_is_unchanged() {
1011 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
1012 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
1013 }
1014
1015 #[test]
1016 fn exec_direct_runs_true() {
1017 let code = super::exec_direct(&["true".to_string()]);
1018 assert_eq!(code, 0);
1019 }
1020
1021 #[test]
1022 fn exec_direct_runs_false() {
1023 let code = super::exec_direct(&["false".to_string()]);
1024 assert_ne!(code, 0);
1025 }
1026
1027 #[test]
1028 fn exec_direct_preserves_args_with_special_chars() {
1029 let code = super::exec_direct(&[
1030 "echo".to_string(),
1031 "hello world".to_string(),
1032 "it's here".to_string(),
1033 "a \"quoted\" thing".to_string(),
1034 ]);
1035 assert_eq!(code, 0);
1036 }
1037
1038 #[test]
1039 fn exec_direct_nonexistent_returns_127() {
1040 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
1041 assert_eq!(code, 127);
1042 }
1043
1044 #[test]
1045 fn exec_argv_empty_returns_127() {
1046 let code = super::exec_argv(&[]);
1047 assert_eq!(code, 127);
1048 }
1049
1050 #[test]
1051 fn exec_argv_runs_simple_command() {
1052 let _lock = crate::core::data_dir::test_env_lock();
1053 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1054 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1055 let code = super::exec_argv(&["true".to_string()]);
1056 assert_eq!(code, 0);
1057 }
1058
1059 #[test]
1060 fn exec_argv_passes_through_when_disabled() {
1061 let _lock = crate::core::data_dir::test_env_lock();
1062 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1063 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
1064 let code = super::exec_argv(&["true".to_string()]);
1065 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1066 assert_eq!(code, 0);
1067 }
1068
1069 #[test]
1073 fn exec_argv_enforces_allowlist_for_disallowed_command() {
1074 let _lock = crate::core::data_dir::test_env_lock();
1075 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1076 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1077 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
1078 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1080 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
1081
1082 let code = super::exec_argv(&["true".to_string()]);
1083
1084 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1085 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1086
1087 assert_eq!(
1088 code, 126,
1089 "non-allowlisted command must be blocked on the -t track path"
1090 );
1091 }
1092
1093 #[test]
1094 fn exec_argv_allows_allowlisted_command() {
1095 let _lock = crate::core::data_dir::test_env_lock();
1096 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1097 crate::test_env::remove_var("LEAN_CTX_DISABLED");
1098 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1099 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1100
1101 let code = super::exec_argv(&["true".to_string()]);
1102
1103 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1104 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1105
1106 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1107 }
1108
1109 #[test]
1110 fn wait_with_limits_captures_output() {
1111 let child = std::process::Command::new("echo")
1112 .arg("hello")
1113 .stdout(std::process::Stdio::piped())
1114 .stderr(std::process::Stdio::piped())
1115 .spawn()
1116 .unwrap();
1117
1118 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1119 let stdout = String::from_utf8_lossy(&output.stdout);
1120 assert!(
1121 stdout.contains("hello"),
1122 "expected 'hello' in output: {stdout}"
1123 );
1124 assert!(output.status.success());
1125 }
1126
1127 #[test]
1128 fn wait_with_limits_truncates_large_output() {
1129 let child = std::process::Command::new("sh")
1131 .args(["-c", "yes 'aaaa' | head -25000"])
1132 .stdout(std::process::Stdio::piped())
1133 .stderr(std::process::Stdio::piped())
1134 .spawn()
1135 .unwrap();
1136
1137 let output =
1138 super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1139 let stdout = String::from_utf8_lossy(&output.stdout);
1140 assert!(
1141 stdout.contains("[lean-ctx: output truncated"),
1142 "expected truncation notice, got len={}: ...{}",
1143 stdout.len(),
1144 &stdout[stdout.len().saturating_sub(80)..]
1145 );
1146 }
1147
1148 #[test]
1149 fn synthetic_failure_status_is_a_failure_without_spawning_anything() {
1150 let status = super::synthetic_failure_status();
1151 assert!(!status.success());
1152 #[cfg(unix)]
1153 {
1154 use std::os::unix::process::ExitStatusExt;
1155 assert_eq!(status.code(), Some(1));
1156 assert_eq!(status.signal(), None);
1157 }
1158 }
1159
1160 #[test]
1161 fn wait_with_limits_truncates_large_stderr() {
1162 let child = std::process::Command::new("sh")
1163 .args(["-c", "yes 'aaaaaaaaaa' | head -200000 >&2"])
1164 .stdout(std::process::Stdio::piped())
1165 .stderr(std::process::Stdio::piped())
1166 .spawn()
1167 .unwrap();
1168
1169 let output = super::wait_with_limits(
1170 child,
1171 1024 * 1024,
1172 std::time::Duration::from_secs(10),
1173 false,
1174 );
1175 let stderr = String::from_utf8_lossy(&output.stderr);
1176 assert!(
1177 stderr.contains("[lean-ctx: stderr truncated"),
1178 "expected stderr truncation notice, got len={}: ...{}",
1179 stderr.len(),
1180 &stderr[stderr.len().saturating_sub(80)..]
1181 );
1182 }
1183
1184 #[test]
1185 fn wait_with_limits_kills_promptly_on_truncation() {
1186 let child = std::process::Command::new("yes")
1187 .stdout(std::process::Stdio::piped())
1188 .stderr(std::process::Stdio::piped())
1189 .spawn()
1190 .unwrap();
1191
1192 let start = std::time::Instant::now();
1193 let output =
1194 super::wait_with_limits(child, 4096, std::time::Duration::from_secs(20), false);
1195 let elapsed = start.elapsed();
1196
1197 assert!(
1198 elapsed < std::time::Duration::from_secs(3),
1199 "truncation should kill promptly, took {elapsed:?} (timeout was 20s)"
1200 );
1201 let stdout = String::from_utf8_lossy(&output.stdout);
1202 assert!(stdout.contains("[lean-ctx: output truncated"));
1203 }
1204
1205 #[test]
1206 fn wait_with_limits_timeout_kills_process() {
1207 let child = std::process::Command::new("sleep")
1208 .arg("60")
1209 .stdout(std::process::Stdio::piped())
1210 .stderr(std::process::Stdio::piped())
1211 .spawn()
1212 .unwrap();
1213
1214 let start = std::time::Instant::now();
1215 let output =
1216 super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1217 let elapsed = start.elapsed();
1218
1219 assert!(
1220 elapsed < std::time::Duration::from_secs(3),
1221 "timeout should kill quickly, took {elapsed:?}"
1222 );
1223 let stdout = String::from_utf8_lossy(&output.stdout);
1224 assert!(stdout.contains("[lean-ctx: output truncated"));
1225 }
1226
1227 #[cfg(unix)]
1233 #[test]
1234 fn wait_with_limits_group_kill_reaps_grandchildren() {
1235 use std::os::unix::process::CommandExt as _;
1236 let mut cmd = std::process::Command::new("sh");
1240 cmd.args(["-c", "sleep 30 & sleep 30"])
1241 .stdin(std::process::Stdio::null())
1242 .stdout(std::process::Stdio::piped())
1243 .stderr(std::process::Stdio::piped());
1244 cmd.process_group(0);
1245 let child = cmd.spawn().unwrap();
1246 let pgid = child.id() as libc::pid_t;
1247
1248 let start = std::time::Instant::now();
1249 let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1250 let elapsed = start.elapsed();
1251
1252 assert!(
1253 elapsed < std::time::Duration::from_secs(5),
1254 "group kill must unblock the reader threads, took {elapsed:?}"
1255 );
1256 let mut group_gone = false;
1259 for _ in 0..50 {
1260 if unsafe { libc::killpg(pgid, 0) } == -1 {
1262 group_gone = true;
1263 break;
1264 }
1265 std::thread::sleep(std::time::Duration::from_millis(20));
1266 }
1267 assert!(group_gone, "process group {pgid} must be fully reaped");
1268 }
1269
1270 #[cfg(unix)]
1274 #[test]
1275 fn stdin_relay_forwards_piped_data() {
1276 use std::io::Write;
1277 use std::os::unix::process::CommandExt as _;
1278
1279 let mut cmd = std::process::Command::new("cat");
1280 cmd.stdin(std::process::Stdio::piped())
1281 .stdout(std::process::Stdio::piped())
1282 .stderr(std::process::Stdio::piped());
1283 cmd.process_group(0);
1284 let mut child = cmd.spawn().expect("failed to spawn cat");
1285
1286 let mut child_stdin = child.stdin.take().unwrap();
1287 std::thread::spawn(move || {
1288 child_stdin.write_all(b"hello from pipe\n").unwrap();
1289 drop(child_stdin);
1290 });
1291
1292 let output = child.wait_with_output().expect("wait failed");
1293 let stdout = String::from_utf8_lossy(&output.stdout);
1294 assert!(
1295 stdout.contains("hello from pipe"),
1296 "#806: piped stdin must reach the child, got: {stdout}"
1297 );
1298 }
1299
1300 #[cfg(unix)]
1303 #[test]
1304 fn stdin_relay_no_data_does_not_hang() {
1305 let start = std::time::Instant::now();
1306 let mut cmd = std::process::Command::new("sh");
1307 cmd.args(["-c", "echo ok"])
1308 .stdin(std::process::Stdio::piped())
1309 .stdout(std::process::Stdio::piped())
1310 .stderr(std::process::Stdio::piped());
1311 use std::os::unix::process::CommandExt as _;
1312 cmd.process_group(0);
1313 let mut child = cmd.spawn().unwrap();
1314 drop(child.stdin.take());
1316 let output = child.wait_with_output().unwrap();
1317 let elapsed = start.elapsed();
1318 assert!(
1319 elapsed < std::time::Duration::from_secs(5),
1320 "must not hang when stdin is closed immediately, took {elapsed:?}"
1321 );
1322 let stdout = String::from_utf8_lossy(&output.stdout);
1323 assert!(stdout.contains("ok"), "command output missing: {stdout}");
1324 }
1325
1326 #[test]
1327 fn heavy_commands_get_higher_byte_limits() {
1328 for cmd in [
1333 "cargo build --release",
1334 "cargo test --lib",
1335 "cargo nextest run",
1336 "npm run build",
1337 "docker build -t myapp .",
1338 "git commit --amend --no-edit",
1341 "git push -u origin HEAD",
1342 "cd /some/path && cargo test --lib",
1345 "cd /foo/bar && cargo build --release",
1346 "cd /workspace; npm ci",
1347 ] {
1348 let (bytes, _) = super::exec_limits(cmd);
1349 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1350 }
1351 }
1352
1353 #[test]
1354 fn normal_commands_get_default_byte_limits() {
1355 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1358 let (bytes, _) = super::exec_limits(cmd);
1359 assert_eq!(
1360 bytes,
1361 super::DEFAULT_MAX_BYTES,
1362 "default byte limit for {cmd}"
1363 );
1364 }
1365 }
1366
1367 #[test]
1368 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1369 let _lock = crate::core::data_dir::test_env_lock();
1371 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1372 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1373 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1374 for v in [
1375 "LEAN_CTX_SHELL_TIMEOUT_MS",
1376 "LEAN_CTX_SHELL_TIMEOUT_SECS",
1377 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1378 ] {
1379 crate::test_env::remove_var(v);
1380 }
1381
1382 assert_eq!(
1385 super::shell_timeout("cargo install --path ."),
1386 super::HEAVY_TIMEOUT
1387 );
1388 assert_eq!(
1389 super::shell_timeout("cargo nextest run"),
1390 super::HEAVY_TIMEOUT
1391 );
1392 assert_eq!(
1393 super::shell_timeout("git commit -m 'wip'"),
1394 super::HEAVY_TIMEOUT
1395 );
1396 assert_eq!(
1397 super::shell_timeout("git push origin main"),
1398 super::HEAVY_TIMEOUT
1399 );
1400 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1401 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1402 assert_eq!(
1404 super::shell_timeout("cd /some/project && cargo test --lib"),
1405 super::HEAVY_TIMEOUT
1406 );
1407 assert_eq!(
1408 super::shell_timeout("cd /workspace && cargo build --release"),
1409 super::HEAVY_TIMEOUT
1410 );
1411 assert_eq!(
1412 super::shell_timeout("cd /app; npm ci"),
1413 super::HEAVY_TIMEOUT
1414 );
1415
1416 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1419 assert_eq!(
1420 super::shell_timeout("cargo build"),
1421 std::time::Duration::from_secs(90)
1422 );
1423 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1424
1425 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1426 assert_eq!(
1427 super::shell_timeout("git status"),
1428 std::time::Duration::from_secs(30)
1429 );
1430 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1431
1432 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1434 assert_eq!(
1435 super::shell_timeout("cargo build"),
1436 std::time::Duration::from_secs(5)
1437 );
1438 assert_eq!(
1439 super::shell_timeout("git status"),
1440 std::time::Duration::from_secs(5)
1441 );
1442 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1443
1444 for (var, saved) in [
1445 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1446 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1447 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1448 ] {
1449 if let Some(v) = saved {
1450 crate::test_env::set_var(var, v);
1451 }
1452 }
1453 }
1454
1455 #[test]
1459 fn task_runners_get_heavy_ceiling() {
1460 let _lock = crate::core::data_dir::test_env_lock();
1461 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1462 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1463 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1464 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1465
1466 assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1467 assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1468 assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1469
1470 if let Some(v) = saved_ms {
1471 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1472 }
1473 if let Some(v) = saved_heavy {
1474 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1475 }
1476 }
1477
1478 #[test]
1482 fn per_call_timeout_override_resolves_and_clamps() {
1483 let _lock = crate::core::data_dir::test_env_lock();
1484 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1485 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1486
1487 assert_eq!(
1488 super::shell_timeout_with_override("git status", Some(300_000)),
1489 std::time::Duration::from_mins(5)
1490 );
1491 assert_eq!(
1492 super::shell_timeout_with_override("cargo build", Some(30_000)),
1493 std::time::Duration::from_secs(30)
1494 );
1495 assert_eq!(
1496 super::shell_timeout_with_override("git status", Some(999_000_000)),
1497 std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1498 );
1499 assert_eq!(
1500 super::shell_timeout_with_override("git status", Some(0)),
1501 super::DEFAULT_TIMEOUT
1502 );
1503 assert_eq!(
1504 super::shell_timeout_with_override("git status", None),
1505 super::DEFAULT_TIMEOUT
1506 );
1507
1508 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1509 assert_eq!(
1510 super::shell_timeout_with_override("git status", Some(300_000)),
1511 std::time::Duration::from_secs(5)
1512 );
1513 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1514 if let Some(v) = saved_ms {
1515 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1516 }
1517 }
1518
1519 #[test]
1521 fn allowlist_enforces_in_hook_child_mode() {
1522 assert!(super::allowlist_must_enforce_inner(true, false, true));
1524 assert!(super::allowlist_must_enforce_inner(true, true, true));
1525 }
1526
1527 #[test]
1528 fn allowlist_enforces_for_non_interactive_callers() {
1529 assert!(super::allowlist_must_enforce_inner(false, false, false));
1531 }
1532
1533 #[test]
1534 fn allowlist_warns_for_interactive_humans() {
1535 assert!(!super::allowlist_must_enforce_inner(false, false, true));
1537 }
1538
1539 #[test]
1540 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1541 assert!(!super::allowlist_must_enforce_inner(false, true, false));
1543 assert!(super::allowlist_must_enforce_inner(true, true, false));
1544 }
1545}