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
103const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
105const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
107
108fn exec_limits(command: &str) -> (usize, std::time::Duration) {
109 if is_heavy_command(command) {
110 (HEAVY_MAX_BYTES, HEAVY_TIMEOUT)
111 } else {
112 (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT)
113 }
114}
115
116fn is_heavy_command(command: &str) -> bool {
117 let cmd = command.trim();
118 let lower = cmd.to_lowercase();
119 static HEAVY_PREFIXES: &[&str] = &[
120 "cargo build",
121 "cargo test",
122 "cargo nextest",
123 "cargo clippy",
124 "cargo check",
125 "cargo install",
126 "cargo bench",
127 "npm run build",
128 "npm install",
129 "npm ci",
130 "pnpm install",
131 "pnpm build",
132 "yarn install",
133 "yarn build",
134 "bun install",
135 "make",
136 "cmake",
137 "bazel build",
138 "bazel test",
139 "gradle build",
140 "gradle test",
141 "mvn package",
142 "mvn install",
143 "mvn test",
144 "go build",
145 "go test",
146 "dotnet build",
147 "dotnet test",
148 "swift build",
149 "swift test",
150 "flutter build",
151 "docker build",
152 "docker compose build",
153 "pip install",
154 "poetry install",
155 "uv sync",
156 "bundle install",
157 "mix compile",
158 ];
159 HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
160}
161
162#[must_use]
168pub(crate) fn heavy_timeout(command: &str) -> Option<std::time::Duration> {
169 is_heavy_command(command).then_some(HEAVY_TIMEOUT)
170}
171
172pub fn exec_argv(args: &[String]) -> i32 {
178 if args.is_empty() {
179 return 127;
180 }
181
182 if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
183 return exec_direct(args);
184 }
185
186 let joined = super::platform::join_command(args);
187 let cfg = config::Config::load();
188 let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
189
190 if policy.is_protected() {
191 let code = exec_direct(args);
192 crate::core::tool_lifecycle::record_shell_command(0, 0);
193 return code;
194 }
195
196 let code = exec_direct(args);
197 crate::core::tool_lifecycle::record_shell_command(0, 0);
198 code
199}
200
201fn exec_direct(args: &[String]) -> i32 {
202 let mut cmd = Command::new(&args[0]);
203 cmd.args(&args[1..])
204 .env("LEAN_CTX_ACTIVE", "1")
205 .stdin(Stdio::inherit())
206 .stdout(Stdio::inherit())
207 .stderr(Stdio::inherit());
208 super::platform::apply_utf8_locale(&mut cmd);
209 let status = cmd.status();
210
211 match status {
212 Ok(s) => s.code().unwrap_or(1),
213 Err(e) => {
214 tracing::error!("lean-ctx: failed to execute: {e}");
215 127
216 }
217 }
218}
219
220fn allowlist_must_enforce() -> bool {
233 let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
234 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
235 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
236 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
237}
238
239fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
242 if hook_child {
243 return true;
244 }
245 if warn_only {
246 return false;
247 }
248 !stderr_is_tty
249}
250
251fn stdout_is_regular_file() -> bool {
264 #[cfg(unix)]
265 {
266 use std::os::unix::io::{AsRawFd, FromRawFd};
267 let fd = io::stdout().as_raw_fd();
268 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
271 file.metadata().is_ok_and(|m| m.is_file())
272 }
273 #[cfg(windows)]
274 {
275 use std::os::windows::io::{AsRawHandle, FromRawHandle};
276 let handle = io::stdout().as_raw_handle();
277 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
280 file.metadata().is_ok_and(|m| m.is_file())
281 }
282 #[cfg(not(any(unix, windows)))]
283 {
284 false
285 }
286}
287
288pub fn exec(command: &str) -> i32 {
289 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
290 if allowlist_must_enforce() {
291 eprintln!("{msg}");
292 eprintln!(
293 "lean-ctx: command blocked by shell allowlist. \
294 Allow it permanently: lean-ctx allow <cmd> — or set \
295 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
296 );
297 return 126;
298 }
299 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
300 }
301
302 let (shell, shell_flag) = super::platform::shell_and_flag();
303 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
304 let command = command.as_str();
305
306 if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
307 return exec_inherit(command, &shell, &shell_flag);
308 }
309
310 let cfg = config::Config::load();
311 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
312 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
313
314 if raw_mode {
315 return exec_inherit_tracked(command, &shell, &shell_flag);
316 }
317
318 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
319
320 if policy == super::output_policy::OutputPolicy::Passthrough {
322 return exec_inherit_tracked(command, &shell, &shell_flag);
323 }
324
325 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
329 return exec_inherit_tracked(command, &shell, &shell_flag);
330 }
331
332 if !force_compress {
333 if io::stdout().is_terminal() {
334 return exec_inherit_tracked(command, &shell, &shell_flag);
335 }
336 let code = exec_inherit(command, &shell, &shell_flag);
337 crate::core::tool_lifecycle::record_shell_command(0, 0);
338 return code;
339 }
340
341 if stdout_is_regular_file() {
350 return exec_inherit_tracked(command, &shell, &shell_flag);
351 }
352
353 exec_buffered(command, &shell, &shell_flag, &cfg)
354}
355
356fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
357 let mut cmd = Command::new(shell);
358 cmd.arg(shell_flag)
359 .arg(command)
360 .env("LEAN_CTX_ACTIVE", "1")
361 .stdin(Stdio::inherit())
362 .stdout(Stdio::inherit())
363 .stderr(Stdio::inherit());
364 super::platform::apply_utf8_locale(&mut cmd);
365 super::platform::apply_profile_free_env(&mut cmd);
366 let status = cmd.status();
367
368 match status {
369 Ok(s) => s.code().unwrap_or(1),
370 Err(e) => {
371 tracing::error!("lean-ctx: failed to execute: {e}");
372 127
373 }
374 }
375}
376
377fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
378 let code = exec_inherit(command, shell, shell_flag);
379 crate::core::tool_lifecycle::record_shell_command(0, 0);
380 code
381}
382
383fn combine_output(stdout: &str, stderr: &str) -> String {
384 if stderr.is_empty() {
385 stdout.to_string()
386 } else if stdout.is_empty() {
387 stderr.to_string()
388 } else {
389 format!("{stdout}\n{stderr}")
390 }
391}
392
393fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
394 #[cfg(windows)]
395 super::platform::set_console_utf8();
396
397 let start = std::time::Instant::now();
398
399 let mut cmd = Command::new(shell);
400
401 #[cfg(windows)]
402 let ps_tmp_path: Option<tempfile::TempPath>;
403 #[cfg(windows)]
404 {
405 if super::platform::is_powershell(shell) {
406 let ps_script = format!(
407 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
408 command
409 );
410 match tempfile::Builder::new()
414 .prefix("lean-ctx-ps-")
415 .suffix(".ps1")
416 .tempfile()
417 {
418 Ok(tmp) => {
419 let tmp_path = tmp.into_temp_path();
420 let _ = std::fs::write(&tmp_path, &ps_script);
421 cmd.args([
422 "-NoProfile",
423 "-ExecutionPolicy",
424 "Bypass",
425 "-File",
426 &tmp_path.to_string_lossy(),
427 ]);
428 ps_tmp_path = Some(tmp_path);
429 }
430 Err(e) => {
431 tracing::warn!(
432 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
433 );
434 cmd.arg(shell_flag);
435 cmd.arg(command);
436 ps_tmp_path = None;
437 }
438 }
439 } else {
440 cmd.arg(shell_flag);
441 cmd.arg(command);
442 ps_tmp_path = None;
443 }
444 }
445 #[cfg(not(windows))]
446 {
447 cmd.arg(shell_flag);
448 cmd.arg(command);
449 }
450
451 cmd.env("LEAN_CTX_ACTIVE", "1")
452 .stdout(Stdio::piped())
453 .stderr(Stdio::piped());
454 super::platform::apply_utf8_locale(&mut cmd);
455 super::platform::apply_profile_free_env(&mut cmd);
456 let child = cmd.spawn();
457
458 let child = match child {
459 Ok(c) => c,
460 Err(e) => {
461 tracing::error!("lean-ctx: failed to execute: {e}");
462 #[cfg(windows)]
463 if let Some(ref tmp) = ps_tmp_path {
464 let _ = std::fs::remove_file(tmp);
465 }
466 return 127;
467 }
468 };
469
470 let (max_bytes, timeout) = exec_limits(command);
471 let output = wait_with_limits(child, max_bytes, timeout);
472
473 let duration_ms = start.elapsed().as_millis();
474 let exit_code = output.status.code().unwrap_or(1);
475 let stdout = super::platform::decode_output(&output.stdout);
476 let stderr = super::platform::decode_output(&output.stderr);
477
478 let full_output = combine_output(&stdout, &stderr);
479 let input_tokens = count_tokens(&full_output);
480
481 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
484
485 let (compressed, output_tokens) =
486 super::compress::compress_and_measure(command, &stdout, &stderr);
487
488 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
489
490 if !compressed.is_empty() {
491 let _ = io::stdout().write_all(compressed.as_bytes());
492 if !compressed.ends_with('\n') {
493 let _ = io::stdout().write_all(b"\n");
494 }
495 }
496 let should_tee = match cfg.tee_mode {
497 config::TeeMode::Always => !full_output.trim().is_empty(),
498 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
499 config::TeeMode::HighCompression => {
500 let orig = full_output.len();
501 let after = compressed.len();
502 let pct = if orig > 0 {
503 ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
504 } else {
505 0.0
506 };
507 pct > 70.0 && orig > 100
508 }
509 config::TeeMode::Never => false,
510 };
511 if should_tee
512 && let Some(path) = super::redact::save_tee(command, &full_output)
513 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
514 {
515 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
516 }
517
518 let threshold = cfg.slow_command_threshold_ms;
519 if threshold > 0 && duration_ms >= threshold as u128 {
520 slow_log::record(command, duration_ms, exit_code);
521 }
522
523 #[cfg(windows)]
524 if let Some(ref tmp) = ps_tmp_path {
525 let _ = std::fs::remove_file(tmp);
526 }
527
528 exit_code
529}
530
531#[cfg(test)]
532mod exec_tests {
533 #[test]
534 fn exec_direct_runs_true() {
535 let code = super::exec_direct(&["true".to_string()]);
536 assert_eq!(code, 0);
537 }
538
539 #[test]
540 fn exec_direct_runs_false() {
541 let code = super::exec_direct(&["false".to_string()]);
542 assert_ne!(code, 0);
543 }
544
545 #[test]
546 fn exec_direct_preserves_args_with_special_chars() {
547 let code = super::exec_direct(&[
548 "echo".to_string(),
549 "hello world".to_string(),
550 "it's here".to_string(),
551 "a \"quoted\" thing".to_string(),
552 ]);
553 assert_eq!(code, 0);
554 }
555
556 #[test]
557 fn exec_direct_nonexistent_returns_127() {
558 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
559 assert_eq!(code, 127);
560 }
561
562 #[test]
563 fn exec_argv_empty_returns_127() {
564 let code = super::exec_argv(&[]);
565 assert_eq!(code, 127);
566 }
567
568 #[test]
569 fn exec_argv_runs_simple_command() {
570 let code = super::exec_argv(&["true".to_string()]);
571 assert_eq!(code, 0);
572 }
573
574 #[test]
575 fn exec_argv_passes_through_when_disabled() {
576 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
577 let code = super::exec_argv(&["true".to_string()]);
578 crate::test_env::remove_var("LEAN_CTX_DISABLED");
579 assert_eq!(code, 0);
580 }
581
582 #[test]
583 fn wait_with_limits_captures_output() {
584 let child = std::process::Command::new("echo")
585 .arg("hello")
586 .stdout(std::process::Stdio::piped())
587 .stderr(std::process::Stdio::piped())
588 .spawn()
589 .unwrap();
590
591 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
592 let stdout = String::from_utf8_lossy(&output.stdout);
593 assert!(
594 stdout.contains("hello"),
595 "expected 'hello' in output: {stdout}"
596 );
597 assert!(output.status.success());
598 }
599
600 #[test]
601 fn wait_with_limits_truncates_large_output() {
602 let child = std::process::Command::new("sh")
604 .args(["-c", "yes 'aaaa' | head -25000"])
605 .stdout(std::process::Stdio::piped())
606 .stderr(std::process::Stdio::piped())
607 .spawn()
608 .unwrap();
609
610 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
611 let stdout = String::from_utf8_lossy(&output.stdout);
612 assert!(
613 stdout.contains("[lean-ctx: output truncated"),
614 "expected truncation notice, got len={}: ...{}",
615 stdout.len(),
616 &stdout[stdout.len().saturating_sub(80)..]
617 );
618 }
619
620 #[test]
621 fn wait_with_limits_timeout_kills_process() {
622 let child = std::process::Command::new("sleep")
623 .arg("60")
624 .stdout(std::process::Stdio::piped())
625 .stderr(std::process::Stdio::piped())
626 .spawn()
627 .unwrap();
628
629 let start = std::time::Instant::now();
630 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
631 let elapsed = start.elapsed();
632
633 assert!(
634 elapsed < std::time::Duration::from_secs(3),
635 "timeout should kill quickly, took {elapsed:?}"
636 );
637 let stdout = String::from_utf8_lossy(&output.stdout);
638 assert!(stdout.contains("[lean-ctx: output truncated"));
639 }
640
641 #[test]
642 fn heavy_commands_get_higher_limits() {
643 let (bytes, timeout) = super::exec_limits("cargo build --release");
644 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
645 assert_eq!(timeout, super::HEAVY_TIMEOUT);
646
647 let (bytes, timeout) = super::exec_limits("cargo test --lib");
648 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
649 assert_eq!(timeout, super::HEAVY_TIMEOUT);
650
651 let (bytes, timeout) = super::exec_limits("cargo nextest run");
652 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
653 assert_eq!(timeout, super::HEAVY_TIMEOUT);
654
655 let (bytes, timeout) = super::exec_limits("npm run build");
656 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
657 assert_eq!(timeout, super::HEAVY_TIMEOUT);
658
659 let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
660 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
661 assert_eq!(timeout, super::HEAVY_TIMEOUT);
662 }
663
664 #[test]
665 fn normal_commands_get_default_limits() {
666 let (bytes, timeout) = super::exec_limits("echo hello");
667 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
668 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
669
670 let (bytes, timeout) = super::exec_limits("git status");
671 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
672 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
673 }
674
675 #[test]
676 fn heavy_timeout_some_for_heavy_none_otherwise() {
677 assert_eq!(
678 super::heavy_timeout("cargo install --path ."),
679 Some(super::HEAVY_TIMEOUT)
680 );
681 assert_eq!(
682 super::heavy_timeout("cargo nextest run"),
683 Some(super::HEAVY_TIMEOUT)
684 );
685 assert_eq!(super::heavy_timeout("git status"), None);
686 assert_eq!(super::heavy_timeout("ls -la"), None);
687 }
688
689 #[test]
691 fn allowlist_enforces_in_hook_child_mode() {
692 assert!(super::allowlist_must_enforce_inner(true, false, true));
694 assert!(super::allowlist_must_enforce_inner(true, true, true));
695 }
696
697 #[test]
698 fn allowlist_enforces_for_non_interactive_callers() {
699 assert!(super::allowlist_must_enforce_inner(false, false, false));
701 }
702
703 #[test]
704 fn allowlist_warns_for_interactive_humans() {
705 assert!(!super::allowlist_must_enforce_inner(false, false, true));
707 }
708
709 #[test]
710 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
711 assert!(!super::allowlist_must_enforce_inner(false, true, false));
713 assert!(super::allowlist_must_enforce_inner(true, true, false));
714 }
715}