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 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
488
489 let (compressed, output_tokens) =
490 super::compress::compress_and_measure(command, &stdout, &stderr);
491
492 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
493
494 if !compressed.is_empty() {
495 let _ = io::stdout().write_all(compressed.as_bytes());
496 if !compressed.ends_with('\n') {
497 let _ = io::stdout().write_all(b"\n");
498 }
499 }
500 let should_tee = match cfg.tee_mode {
501 config::TeeMode::Always => !full_output.trim().is_empty(),
502 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
503 config::TeeMode::HighCompression => {
504 let orig = full_output.len();
505 let after = compressed.len();
506 let pct = if orig > 0 {
507 ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
508 } else {
509 0.0
510 };
511 pct > 70.0 && orig > 100
512 }
513 config::TeeMode::Never => false,
514 };
515 if should_tee
516 && let Some(path) = super::redact::save_tee(command, &full_output)
517 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
518 {
519 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
520 }
521
522 let threshold = cfg.slow_command_threshold_ms;
523 if threshold > 0 && duration_ms >= threshold as u128 {
524 slow_log::record(command, duration_ms, exit_code);
525 }
526
527 #[cfg(windows)]
528 if let Some(ref tmp) = ps_tmp_path {
529 let _ = std::fs::remove_file(tmp);
530 }
531
532 exit_code
533}
534
535#[cfg(test)]
536mod exec_tests {
537 #[test]
538 fn exec_direct_runs_true() {
539 let code = super::exec_direct(&["true".to_string()]);
540 assert_eq!(code, 0);
541 }
542
543 #[test]
544 fn exec_direct_runs_false() {
545 let code = super::exec_direct(&["false".to_string()]);
546 assert_ne!(code, 0);
547 }
548
549 #[test]
550 fn exec_direct_preserves_args_with_special_chars() {
551 let code = super::exec_direct(&[
552 "echo".to_string(),
553 "hello world".to_string(),
554 "it's here".to_string(),
555 "a \"quoted\" thing".to_string(),
556 ]);
557 assert_eq!(code, 0);
558 }
559
560 #[test]
561 fn exec_direct_nonexistent_returns_127() {
562 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
563 assert_eq!(code, 127);
564 }
565
566 #[test]
567 fn exec_argv_empty_returns_127() {
568 let code = super::exec_argv(&[]);
569 assert_eq!(code, 127);
570 }
571
572 #[test]
573 fn exec_argv_runs_simple_command() {
574 let code = super::exec_argv(&["true".to_string()]);
575 assert_eq!(code, 0);
576 }
577
578 #[test]
579 fn exec_argv_passes_through_when_disabled() {
580 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
581 let code = super::exec_argv(&["true".to_string()]);
582 crate::test_env::remove_var("LEAN_CTX_DISABLED");
583 assert_eq!(code, 0);
584 }
585
586 #[test]
587 fn wait_with_limits_captures_output() {
588 let child = std::process::Command::new("echo")
589 .arg("hello")
590 .stdout(std::process::Stdio::piped())
591 .stderr(std::process::Stdio::piped())
592 .spawn()
593 .unwrap();
594
595 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
596 let stdout = String::from_utf8_lossy(&output.stdout);
597 assert!(
598 stdout.contains("hello"),
599 "expected 'hello' in output: {stdout}"
600 );
601 assert!(output.status.success());
602 }
603
604 #[test]
605 fn wait_with_limits_truncates_large_output() {
606 let child = std::process::Command::new("sh")
608 .args(["-c", "yes 'aaaa' | head -25000"])
609 .stdout(std::process::Stdio::piped())
610 .stderr(std::process::Stdio::piped())
611 .spawn()
612 .unwrap();
613
614 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
615 let stdout = String::from_utf8_lossy(&output.stdout);
616 assert!(
617 stdout.contains("[lean-ctx: output truncated"),
618 "expected truncation notice, got len={}: ...{}",
619 stdout.len(),
620 &stdout[stdout.len().saturating_sub(80)..]
621 );
622 }
623
624 #[test]
625 fn wait_with_limits_timeout_kills_process() {
626 let child = std::process::Command::new("sleep")
627 .arg("60")
628 .stdout(std::process::Stdio::piped())
629 .stderr(std::process::Stdio::piped())
630 .spawn()
631 .unwrap();
632
633 let start = std::time::Instant::now();
634 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
635 let elapsed = start.elapsed();
636
637 assert!(
638 elapsed < std::time::Duration::from_secs(3),
639 "timeout should kill quickly, took {elapsed:?}"
640 );
641 let stdout = String::from_utf8_lossy(&output.stdout);
642 assert!(stdout.contains("[lean-ctx: output truncated"));
643 }
644
645 #[test]
646 fn heavy_commands_get_higher_limits() {
647 let (bytes, timeout) = super::exec_limits("cargo build --release");
648 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
649 assert_eq!(timeout, super::HEAVY_TIMEOUT);
650
651 let (bytes, timeout) = super::exec_limits("cargo test --lib");
652 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
653 assert_eq!(timeout, super::HEAVY_TIMEOUT);
654
655 let (bytes, timeout) = super::exec_limits("cargo nextest run");
656 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
657 assert_eq!(timeout, super::HEAVY_TIMEOUT);
658
659 let (bytes, timeout) = super::exec_limits("npm run build");
660 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
661 assert_eq!(timeout, super::HEAVY_TIMEOUT);
662
663 let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
664 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
665 assert_eq!(timeout, super::HEAVY_TIMEOUT);
666 }
667
668 #[test]
669 fn normal_commands_get_default_limits() {
670 let (bytes, timeout) = super::exec_limits("echo hello");
671 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
672 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
673
674 let (bytes, timeout) = super::exec_limits("git status");
675 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
676 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
677 }
678
679 #[test]
680 fn heavy_timeout_some_for_heavy_none_otherwise() {
681 assert_eq!(
682 super::heavy_timeout("cargo install --path ."),
683 Some(super::HEAVY_TIMEOUT)
684 );
685 assert_eq!(
686 super::heavy_timeout("cargo nextest run"),
687 Some(super::HEAVY_TIMEOUT)
688 );
689 assert_eq!(super::heavy_timeout("git status"), None);
690 assert_eq!(super::heavy_timeout("ls -la"), None);
691 }
692
693 #[test]
695 fn allowlist_enforces_in_hook_child_mode() {
696 assert!(super::allowlist_must_enforce_inner(true, false, true));
698 assert!(super::allowlist_must_enforce_inner(true, true, true));
699 }
700
701 #[test]
702 fn allowlist_enforces_for_non_interactive_callers() {
703 assert!(super::allowlist_must_enforce_inner(false, false, false));
705 }
706
707 #[test]
708 fn allowlist_warns_for_interactive_humans() {
709 assert!(!super::allowlist_must_enforce_inner(false, false, true));
711 }
712
713 #[test]
714 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
715 assert!(!super::allowlist_must_enforce_inner(false, true, false));
717 assert!(super::allowlist_must_enforce_inner(true, true, false));
718 }
719}