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