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 if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
194 return std::time::Duration::from_millis(ms);
195 }
196 if is_heavy_command(command) {
197 if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
198 .or_else(|| config::Config::load().shell_heavy_timeout_secs)
199 {
200 return std::time::Duration::from_secs(secs);
201 }
202 HEAVY_TIMEOUT
203 } else {
204 if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
205 .or_else(|| config::Config::load().shell_timeout_secs)
206 {
207 return std::time::Duration::from_secs(secs);
208 }
209 DEFAULT_TIMEOUT
210 }
211}
212
213fn env_u64(var: &str) -> Option<u64> {
216 std::env::var(var)
217 .ok()
218 .and_then(|v| v.parse::<u64>().ok())
219 .filter(|n| *n > 0)
220}
221
222fn is_heavy_command(command: &str) -> bool {
223 let cmd = command.trim();
224 let lower = cmd.to_lowercase();
225 static HEAVY_PREFIXES: &[&str] = &[
226 "cargo build",
227 "cargo test",
228 "cargo nextest",
229 "cargo clippy",
230 "cargo check",
231 "cargo install",
232 "cargo bench",
233 "npm run build",
234 "npm install",
235 "npm ci",
236 "pnpm install",
237 "pnpm build",
238 "yarn install",
239 "yarn build",
240 "bun install",
241 "make",
242 "cmake",
243 "bazel build",
244 "bazel test",
245 "gradle build",
246 "gradle test",
247 "mvn package",
248 "mvn install",
249 "mvn test",
250 "go build",
251 "go test",
252 "dotnet build",
253 "dotnet test",
254 "swift build",
255 "swift test",
256 "flutter build",
257 "docker build",
258 "docker compose build",
259 "pip install",
260 "poetry install",
261 "uv sync",
262 "bundle install",
263 "mix compile",
264 "git commit",
271 "git push",
272 ];
273 HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
274}
275
276pub fn exec_argv(args: &[String]) -> i32 {
282 if args.is_empty() {
283 return 127;
284 }
285
286 let joined = super::platform::join_command(args);
291
292 if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
296 return exec(&u.rebuild());
297 }
298
299 if let Some(code) = allowlist_gate(&joined) {
305 return code;
306 }
307
308 if super::reentry::should_pass_through() {
309 return exec_direct(args);
310 }
311
312 let cfg = config::Config::load();
313 let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
314
315 if policy.is_protected() {
316 let code = exec_direct(args);
317 crate::core::tool_lifecycle::record_shell_command(0, 0);
318 return code;
319 }
320
321 let code = exec_direct(args);
322 crate::core::tool_lifecycle::record_shell_command(0, 0);
323 code
324}
325
326fn exec_direct(args: &[String]) -> i32 {
327 let mut cmd = Command::new(&args[0]);
328 cmd.args(&args[1..])
329 .stdin(Stdio::inherit())
330 .stdout(Stdio::inherit())
331 .stderr(Stdio::inherit());
332 super::reentry::mark_child(&mut cmd);
333 super::platform::apply_utf8_locale(&mut cmd);
334 let status = cmd.status();
335
336 match status {
337 Ok(s) => s.code().unwrap_or(1),
338 Err(e) => {
339 tracing::error!("lean-ctx: failed to execute: {e}");
340 127
341 }
342 }
343}
344
345fn allowlist_must_enforce() -> bool {
358 let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
359 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
360 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
361 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
362}
363
364fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
367 if hook_child {
368 return true;
369 }
370 if warn_only {
371 return false;
372 }
373 !stderr_is_tty
374}
375
376fn stdout_is_regular_file() -> bool {
389 #[cfg(unix)]
390 {
391 use std::os::unix::io::{AsRawFd, FromRawFd};
392 let fd = io::stdout().as_raw_fd();
393 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
396 file.metadata().is_ok_and(|m| m.is_file())
397 }
398 #[cfg(windows)]
399 {
400 use std::os::windows::io::{AsRawHandle, FromRawHandle};
401 let handle = io::stdout().as_raw_handle();
402 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
405 file.metadata().is_ok_and(|m| m.is_file())
406 }
407 #[cfg(not(any(unix, windows)))]
408 {
409 false
410 }
411}
412
413fn allowlist_gate(command: &str) -> Option<i32> {
421 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
422 if allowlist_must_enforce() {
423 eprintln!("{msg}");
424 eprintln!(
425 "lean-ctx: command blocked by shell allowlist. \
426 Allow it permanently: lean-ctx allow <cmd> — or set \
427 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
428 );
429 return Some(126);
430 }
431 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
432 }
433 None
434}
435
436pub fn exec(command: &str) -> i32 {
437 let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
443 let mut collapsed_nested = false;
444 let collapsed;
445 let command = unwrapped.as_deref().unwrap_or(command);
446 let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
447 collapsed_nested = true;
448 collapsed = c;
449 collapsed.as_str()
450 } else {
451 command
452 };
453
454 if let Some(code) = allowlist_gate(command) {
455 return code;
456 }
457
458 let (shell, shell_flag) = super::platform::shell_and_flag();
459 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
460 let command = command.as_str();
461
462 if super::reentry::is_disabled() {
463 return exec_inherit(command, &shell, &shell_flag);
464 }
465 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
466 return exec_shell_default(command, &shell, &shell_flag);
467 }
468
469 let cfg = config::Config::load();
470 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
471 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
472
473 if raw_mode {
474 return exec_inherit_tracked(command, &shell, &shell_flag);
475 }
476
477 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
478
479 if policy == super::output_policy::OutputPolicy::Passthrough {
481 return exec_inherit_tracked(command, &shell, &shell_flag);
482 }
483
484 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
488 return exec_inherit_tracked(command, &shell, &shell_flag);
489 }
490
491 if !force_compress {
492 if io::stdout().is_terminal() {
493 return exec_inherit_tracked(command, &shell, &shell_flag);
494 }
495 let code = exec_inherit(command, &shell, &shell_flag);
496 crate::core::tool_lifecycle::record_shell_command(0, 0);
497 return code;
498 }
499
500 if stdout_is_regular_file() {
509 return exec_inherit_tracked(command, &shell, &shell_flag);
510 }
511
512 exec_buffered(command, &shell, &shell_flag, &cfg)
513}
514
515fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
516 let mut current = command.trim().to_string();
517 let mut changed = false;
518
519 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
520 if next == current {
521 break;
522 }
523 current = next;
524 changed = true;
525 }
526
527 changed.then_some(current)
528}
529
530fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
531 super::reentry::is_wrapped() && !collapsed_nested
535}
536
537fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
538 let words = split_simple_shell_words(command)?;
539 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
540 return None;
541 }
542 if words[1].value != "-c" && words[1].value != "exec" {
543 return None;
544 }
545 if words[2..].iter().any(|w| {
546 matches!(
547 w.value.as_str(),
548 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
549 )
550 }) {
551 return None;
552 }
553 if words.len() == 3 {
554 Some(words[2].value.trim().to_string())
555 } else {
556 Some(command[words[2].start..].trim().to_string())
557 }
558}
559
560fn is_lean_ctx_bin(word: &str) -> bool {
561 std::path::Path::new(word)
562 .file_name()
563 .and_then(|name| name.to_str())
564 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
565}
566
567struct SimpleShellWord {
568 value: String,
569 start: usize,
570}
571
572fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
573 let mut words = Vec::new();
574 let mut current = String::new();
575 let mut current_start: Option<usize> = None;
576 let mut chars = command.char_indices().peekable();
577 let mut quote: Option<char> = None;
578
579 while let Some((idx, ch)) = chars.next() {
580 match quote {
581 Some('\'') if ch == '\'' => quote = None,
582 Some('"') if ch == '"' => quote = None,
583 None if ch == '\'' || ch == '"' => {
584 current_start.get_or_insert(idx);
585 quote = Some(ch);
586 }
587 Some('"') | None if ch == '\\' => {
588 current_start.get_or_insert(idx);
589 if let Some((_, next)) = chars.next() {
590 current.push(next);
591 }
592 }
593 None if ch.is_whitespace() => {
594 if let Some(start) = current_start.take() {
595 words.push(SimpleShellWord {
596 value: std::mem::take(&mut current),
597 start,
598 });
599 }
600 }
601 Some(_) | None => {
602 current_start.get_or_insert(idx);
603 current.push(ch);
604 }
605 }
606 }
607
608 if quote.is_some() {
609 return None;
610 }
611 if let Some(start) = current_start {
612 words.push(SimpleShellWord {
613 value: current,
614 start,
615 });
616 }
617 (!words.is_empty()).then_some(words)
618}
619
620fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
621 let mut cmd = Command::new(shell);
622 cmd.arg(shell_flag)
623 .arg(command)
624 .stdin(Stdio::inherit())
625 .stdout(Stdio::inherit())
626 .stderr(Stdio::inherit());
627 super::reentry::mark_child(&mut cmd);
628 super::platform::apply_utf8_locale(&mut cmd);
629 super::platform::apply_profile_free_env(&mut cmd);
630 let status = cmd.status();
631
632 match status {
633 Ok(s) => s.code().unwrap_or(1),
634 Err(e) => {
635 tracing::error!("lean-ctx: failed to execute: {e}");
636 127
637 }
638 }
639}
640
641fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
642 let mut cmd = Command::new(shell);
643 cmd.arg(shell_flag)
644 .arg(command)
645 .stdin(Stdio::inherit())
646 .stdout(Stdio::inherit())
647 .stderr(Stdio::inherit());
648 super::reentry::clear_shell_default_markers(&mut cmd);
649 super::platform::apply_utf8_locale(&mut cmd);
650 super::platform::apply_profile_free_env(&mut cmd);
651 let status = cmd.status();
652
653 match status {
654 Ok(s) => s.code().unwrap_or(1),
655 Err(e) => {
656 eprintln!("lean-ctx: failed to execute '{command}': {e}");
657 127
658 }
659 }
660}
661
662fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
663 let code = exec_inherit(command, shell, shell_flag);
664 crate::core::tool_lifecycle::record_shell_command(0, 0);
665 code
666}
667
668pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
672
673pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
677 match (stdout.is_empty(), stderr.is_empty()) {
678 (_, true) => stdout.to_string(),
679 (true, false) => stderr.to_string(),
680 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
681 (false, false) => format!("{stdout}\n{stderr}"),
682 }
683}
684
685fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
686 #[cfg(windows)]
687 super::platform::set_console_utf8();
688
689 let start = std::time::Instant::now();
690
691 let mut cmd = Command::new(shell);
692
693 #[cfg(windows)]
694 let ps_tmp_path: Option<tempfile::TempPath>;
695 #[cfg(windows)]
696 {
697 if super::platform::is_powershell(shell) {
698 let ps_script = format!(
699 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
700 command
701 );
702 match tempfile::Builder::new()
706 .prefix("lean-ctx-ps-")
707 .suffix(".ps1")
708 .tempfile()
709 {
710 Ok(tmp) => {
711 let tmp_path = tmp.into_temp_path();
712 let _ = std::fs::write(&tmp_path, &ps_script);
713 cmd.args([
714 "-NoProfile",
715 "-ExecutionPolicy",
716 "Bypass",
717 "-File",
718 &tmp_path.to_string_lossy(),
719 ]);
720 ps_tmp_path = Some(tmp_path);
721 }
722 Err(e) => {
723 tracing::warn!(
724 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
725 );
726 cmd.arg(shell_flag);
727 cmd.arg(command);
728 ps_tmp_path = None;
729 }
730 }
731 } else {
732 cmd.arg(shell_flag);
733 cmd.arg(command);
734 ps_tmp_path = None;
735 }
736 }
737 #[cfg(not(windows))]
738 {
739 cmd.arg(shell_flag);
740 cmd.arg(command);
741 }
742
743 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
744 super::reentry::mark_child(&mut cmd);
745 super::platform::apply_utf8_locale(&mut cmd);
746 super::platform::apply_profile_free_env(&mut cmd);
747 let child = cmd.spawn();
748
749 let child = match child {
750 Ok(c) => c,
751 Err(e) => {
752 tracing::error!("lean-ctx: failed to execute: {e}");
753 #[cfg(windows)]
754 if let Some(ref tmp) = ps_tmp_path {
755 let _ = std::fs::remove_file(tmp);
756 }
757 return 127;
758 }
759 };
760
761 let (max_bytes, timeout) = exec_limits(command);
762 let output = wait_with_limits(child, max_bytes, timeout);
763
764 let duration_ms = start.elapsed().as_millis();
765 let exit_code = output.status.code().unwrap_or(1);
766 let stdout = super::platform::decode_output(&output.stdout);
767 let stderr = super::platform::decode_output(&output.stderr);
768
769 let full_output = combine_streams(&stdout, &stderr, exit_code);
770 let input_tokens = count_tokens(&full_output);
771
772 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
775
776 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
779
780 let (compressed, output_tokens) =
781 super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
782
783 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
784
785 if !compressed.is_empty() {
786 let _ = io::stdout().write_all(compressed.as_bytes());
787 if !compressed.ends_with('\n') {
788 let _ = io::stdout().write_all(b"\n");
789 }
790 }
791 let should_tee = super::tee_policy::should_tee(
794 &cfg.tee_mode,
795 exit_code,
796 full_output.trim().is_empty(),
797 input_tokens,
798 output_tokens,
799 );
800 if should_tee
801 && let Some(path) = super::redact::save_tee(command, &full_output)
802 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
803 {
804 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
805 }
806
807 let threshold = cfg.slow_command_threshold_ms;
808 if threshold > 0 && duration_ms >= threshold as u128 {
809 slow_log::record(command, duration_ms, exit_code);
810 }
811
812 #[cfg(windows)]
813 if let Some(ref tmp) = ps_tmp_path {
814 let _ = std::fs::remove_file(tmp);
815 }
816
817 exit_code
818}
819
820#[cfg(test)]
821mod exec_tests {
822 #[test]
823 fn combine_streams_labels_stderr_on_failure() {
824 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
825 assert_eq!(
826 out,
827 format!(
828 "build ok\n{}\nlinker: undefined symbol",
829 super::STDERR_LABEL
830 )
831 );
832 }
833
834 #[test]
835 fn combine_streams_plain_join_on_success() {
836 let out = super::combine_streams("step 1", "warning: noop", 0);
837 assert_eq!(out, "step 1\nwarning: noop");
838 assert!(!out.contains(super::STDERR_LABEL));
839 }
840
841 #[test]
842 fn combine_streams_single_stream_is_unchanged() {
843 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
844 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
845 }
846
847 #[test]
848 fn exec_direct_runs_true() {
849 let code = super::exec_direct(&["true".to_string()]);
850 assert_eq!(code, 0);
851 }
852
853 #[test]
854 fn exec_direct_runs_false() {
855 let code = super::exec_direct(&["false".to_string()]);
856 assert_ne!(code, 0);
857 }
858
859 #[test]
860 fn exec_direct_preserves_args_with_special_chars() {
861 let code = super::exec_direct(&[
862 "echo".to_string(),
863 "hello world".to_string(),
864 "it's here".to_string(),
865 "a \"quoted\" thing".to_string(),
866 ]);
867 assert_eq!(code, 0);
868 }
869
870 #[test]
871 fn exec_direct_nonexistent_returns_127() {
872 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
873 assert_eq!(code, 127);
874 }
875
876 #[test]
877 fn exec_argv_empty_returns_127() {
878 let code = super::exec_argv(&[]);
879 assert_eq!(code, 127);
880 }
881
882 #[test]
883 fn exec_argv_runs_simple_command() {
884 let _lock = crate::core::data_dir::test_env_lock();
885 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
886 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
887 let code = super::exec_argv(&["true".to_string()]);
888 assert_eq!(code, 0);
889 }
890
891 #[test]
892 fn exec_argv_passes_through_when_disabled() {
893 let _lock = crate::core::data_dir::test_env_lock();
894 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
895 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
896 let code = super::exec_argv(&["true".to_string()]);
897 crate::test_env::remove_var("LEAN_CTX_DISABLED");
898 assert_eq!(code, 0);
899 }
900
901 #[test]
905 fn exec_argv_enforces_allowlist_for_disallowed_command() {
906 let _lock = crate::core::data_dir::test_env_lock();
907 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
908 crate::test_env::remove_var("LEAN_CTX_DISABLED");
909 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
910 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
912 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
913
914 let code = super::exec_argv(&["true".to_string()]);
915
916 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
917 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
918
919 assert_eq!(
920 code, 126,
921 "non-allowlisted command must be blocked on the -t track path"
922 );
923 }
924
925 #[test]
926 fn exec_argv_allows_allowlisted_command() {
927 let _lock = crate::core::data_dir::test_env_lock();
928 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
929 crate::test_env::remove_var("LEAN_CTX_DISABLED");
930 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
931 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
932
933 let code = super::exec_argv(&["true".to_string()]);
934
935 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
936 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
937
938 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
939 }
940
941 #[test]
942 fn wait_with_limits_captures_output() {
943 let child = std::process::Command::new("echo")
944 .arg("hello")
945 .stdout(std::process::Stdio::piped())
946 .stderr(std::process::Stdio::piped())
947 .spawn()
948 .unwrap();
949
950 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
951 let stdout = String::from_utf8_lossy(&output.stdout);
952 assert!(
953 stdout.contains("hello"),
954 "expected 'hello' in output: {stdout}"
955 );
956 assert!(output.status.success());
957 }
958
959 #[test]
960 fn wait_with_limits_truncates_large_output() {
961 let child = std::process::Command::new("sh")
963 .args(["-c", "yes 'aaaa' | head -25000"])
964 .stdout(std::process::Stdio::piped())
965 .stderr(std::process::Stdio::piped())
966 .spawn()
967 .unwrap();
968
969 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
970 let stdout = String::from_utf8_lossy(&output.stdout);
971 assert!(
972 stdout.contains("[lean-ctx: output truncated"),
973 "expected truncation notice, got len={}: ...{}",
974 stdout.len(),
975 &stdout[stdout.len().saturating_sub(80)..]
976 );
977 }
978
979 #[test]
980 fn wait_with_limits_timeout_kills_process() {
981 let child = std::process::Command::new("sleep")
982 .arg("60")
983 .stdout(std::process::Stdio::piped())
984 .stderr(std::process::Stdio::piped())
985 .spawn()
986 .unwrap();
987
988 let start = std::time::Instant::now();
989 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
990 let elapsed = start.elapsed();
991
992 assert!(
993 elapsed < std::time::Duration::from_secs(3),
994 "timeout should kill quickly, took {elapsed:?}"
995 );
996 let stdout = String::from_utf8_lossy(&output.stdout);
997 assert!(stdout.contains("[lean-ctx: output truncated"));
998 }
999
1000 #[test]
1001 fn heavy_commands_get_higher_byte_limits() {
1002 for cmd in [
1007 "cargo build --release",
1008 "cargo test --lib",
1009 "cargo nextest run",
1010 "npm run build",
1011 "docker build -t myapp .",
1012 "git commit --amend --no-edit",
1015 "git push -u origin HEAD",
1016 ] {
1017 let (bytes, _) = super::exec_limits(cmd);
1018 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1019 }
1020 }
1021
1022 #[test]
1023 fn normal_commands_get_default_byte_limits() {
1024 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1027 let (bytes, _) = super::exec_limits(cmd);
1028 assert_eq!(
1029 bytes,
1030 super::DEFAULT_MAX_BYTES,
1031 "default byte limit for {cmd}"
1032 );
1033 }
1034 }
1035
1036 #[test]
1037 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1038 let _lock = crate::core::data_dir::test_env_lock();
1040 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1041 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1042 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1043 for v in [
1044 "LEAN_CTX_SHELL_TIMEOUT_MS",
1045 "LEAN_CTX_SHELL_TIMEOUT_SECS",
1046 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1047 ] {
1048 crate::test_env::remove_var(v);
1049 }
1050
1051 assert_eq!(
1054 super::shell_timeout("cargo install --path ."),
1055 super::HEAVY_TIMEOUT
1056 );
1057 assert_eq!(
1058 super::shell_timeout("cargo nextest run"),
1059 super::HEAVY_TIMEOUT
1060 );
1061 assert_eq!(
1062 super::shell_timeout("git commit -m 'wip'"),
1063 super::HEAVY_TIMEOUT
1064 );
1065 assert_eq!(
1066 super::shell_timeout("git push origin main"),
1067 super::HEAVY_TIMEOUT
1068 );
1069 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1070 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1071
1072 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1075 assert_eq!(
1076 super::shell_timeout("cargo build"),
1077 std::time::Duration::from_secs(90)
1078 );
1079 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1080
1081 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1082 assert_eq!(
1083 super::shell_timeout("git status"),
1084 std::time::Duration::from_secs(30)
1085 );
1086 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1087
1088 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1090 assert_eq!(
1091 super::shell_timeout("cargo build"),
1092 std::time::Duration::from_secs(5)
1093 );
1094 assert_eq!(
1095 super::shell_timeout("git status"),
1096 std::time::Duration::from_secs(5)
1097 );
1098 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1099
1100 for (var, saved) in [
1101 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1102 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1103 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1104 ] {
1105 if let Some(v) = saved {
1106 crate::test_env::set_var(var, v);
1107 }
1108 }
1109 }
1110
1111 #[test]
1113 fn allowlist_enforces_in_hook_child_mode() {
1114 assert!(super::allowlist_must_enforce_inner(true, false, true));
1116 assert!(super::allowlist_must_enforce_inner(true, true, true));
1117 }
1118
1119 #[test]
1120 fn allowlist_enforces_for_non_interactive_callers() {
1121 assert!(super::allowlist_must_enforce_inner(false, false, false));
1123 }
1124
1125 #[test]
1126 fn allowlist_warns_for_interactive_humans() {
1127 assert!(!super::allowlist_must_enforce_inner(false, false, true));
1129 }
1130
1131 #[test]
1132 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1133 assert!(!super::allowlist_must_enforce_inner(false, true, false));
1135 assert!(super::allowlist_must_enforce_inner(true, true, false));
1136 }
1137}