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 let status = cmd.status();
366
367 match status {
368 Ok(s) => s.code().unwrap_or(1),
369 Err(e) => {
370 tracing::error!("lean-ctx: failed to execute: {e}");
371 127
372 }
373 }
374}
375
376fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
377 let code = exec_inherit(command, shell, shell_flag);
378 crate::core::tool_lifecycle::record_shell_command(0, 0);
379 code
380}
381
382fn combine_output(stdout: &str, stderr: &str) -> String {
383 if stderr.is_empty() {
384 stdout.to_string()
385 } else if stdout.is_empty() {
386 stderr.to_string()
387 } else {
388 format!("{stdout}\n{stderr}")
389 }
390}
391
392fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
393 #[cfg(windows)]
394 super::platform::set_console_utf8();
395
396 let start = std::time::Instant::now();
397
398 let mut cmd = Command::new(shell);
399
400 #[cfg(windows)]
401 let ps_tmp_path: Option<tempfile::TempPath>;
402 #[cfg(windows)]
403 {
404 if super::platform::is_powershell(shell) {
405 let ps_script = format!(
406 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
407 command
408 );
409 match tempfile::Builder::new()
413 .prefix("lean-ctx-ps-")
414 .suffix(".ps1")
415 .tempfile()
416 {
417 Ok(tmp) => {
418 let tmp_path = tmp.into_temp_path();
419 let _ = std::fs::write(&tmp_path, &ps_script);
420 cmd.args([
421 "-NoProfile",
422 "-ExecutionPolicy",
423 "Bypass",
424 "-File",
425 &tmp_path.to_string_lossy(),
426 ]);
427 ps_tmp_path = Some(tmp_path);
428 }
429 Err(e) => {
430 tracing::warn!(
431 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
432 );
433 cmd.arg(shell_flag);
434 cmd.arg(command);
435 ps_tmp_path = None;
436 }
437 }
438 } else {
439 cmd.arg(shell_flag);
440 cmd.arg(command);
441 ps_tmp_path = None;
442 }
443 }
444 #[cfg(not(windows))]
445 {
446 cmd.arg(shell_flag);
447 cmd.arg(command);
448 }
449
450 cmd.env("LEAN_CTX_ACTIVE", "1")
451 .stdout(Stdio::piped())
452 .stderr(Stdio::piped());
453 super::platform::apply_utf8_locale(&mut cmd);
454 let child = cmd.spawn();
455
456 let child = match child {
457 Ok(c) => c,
458 Err(e) => {
459 tracing::error!("lean-ctx: failed to execute: {e}");
460 #[cfg(windows)]
461 if let Some(ref tmp) = ps_tmp_path {
462 let _ = std::fs::remove_file(tmp);
463 }
464 return 127;
465 }
466 };
467
468 let (max_bytes, timeout) = exec_limits(command);
469 let output = wait_with_limits(child, max_bytes, timeout);
470
471 let duration_ms = start.elapsed().as_millis();
472 let exit_code = output.status.code().unwrap_or(1);
473 let stdout = super::platform::decode_output(&output.stdout);
474 let stderr = super::platform::decode_output(&output.stderr);
475
476 let full_output = combine_output(&stdout, &stderr);
477 let input_tokens = count_tokens(&full_output);
478
479 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
482
483 let (compressed, output_tokens) =
484 super::compress::compress_and_measure(command, &stdout, &stderr);
485
486 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
487
488 if !compressed.is_empty() {
489 let _ = io::stdout().write_all(compressed.as_bytes());
490 if !compressed.ends_with('\n') {
491 let _ = io::stdout().write_all(b"\n");
492 }
493 }
494 let should_tee = match cfg.tee_mode {
495 config::TeeMode::Always => !full_output.trim().is_empty(),
496 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
497 config::TeeMode::HighCompression => {
498 let orig = full_output.len();
499 let after = compressed.len();
500 let pct = if orig > 0 {
501 ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
502 } else {
503 0.0
504 };
505 pct > 70.0 && orig > 100
506 }
507 config::TeeMode::Never => false,
508 };
509 if should_tee
510 && let Some(path) = super::redact::save_tee(command, &full_output)
511 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
512 {
513 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
514 }
515
516 let threshold = cfg.slow_command_threshold_ms;
517 if threshold > 0 && duration_ms >= threshold as u128 {
518 slow_log::record(command, duration_ms, exit_code);
519 }
520
521 #[cfg(windows)]
522 if let Some(ref tmp) = ps_tmp_path {
523 let _ = std::fs::remove_file(tmp);
524 }
525
526 exit_code
527}
528
529#[cfg(test)]
530mod exec_tests {
531 #[test]
532 fn exec_direct_runs_true() {
533 let code = super::exec_direct(&["true".to_string()]);
534 assert_eq!(code, 0);
535 }
536
537 #[test]
538 fn exec_direct_runs_false() {
539 let code = super::exec_direct(&["false".to_string()]);
540 assert_ne!(code, 0);
541 }
542
543 #[test]
544 fn exec_direct_preserves_args_with_special_chars() {
545 let code = super::exec_direct(&[
546 "echo".to_string(),
547 "hello world".to_string(),
548 "it's here".to_string(),
549 "a \"quoted\" thing".to_string(),
550 ]);
551 assert_eq!(code, 0);
552 }
553
554 #[test]
555 fn exec_direct_nonexistent_returns_127() {
556 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
557 assert_eq!(code, 127);
558 }
559
560 #[test]
561 fn exec_argv_empty_returns_127() {
562 let code = super::exec_argv(&[]);
563 assert_eq!(code, 127);
564 }
565
566 #[test]
567 fn exec_argv_runs_simple_command() {
568 let code = super::exec_argv(&["true".to_string()]);
569 assert_eq!(code, 0);
570 }
571
572 #[test]
573 fn exec_argv_passes_through_when_disabled() {
574 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
575 let code = super::exec_argv(&["true".to_string()]);
576 crate::test_env::remove_var("LEAN_CTX_DISABLED");
577 assert_eq!(code, 0);
578 }
579
580 #[test]
581 fn wait_with_limits_captures_output() {
582 let child = std::process::Command::new("echo")
583 .arg("hello")
584 .stdout(std::process::Stdio::piped())
585 .stderr(std::process::Stdio::piped())
586 .spawn()
587 .unwrap();
588
589 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
590 let stdout = String::from_utf8_lossy(&output.stdout);
591 assert!(
592 stdout.contains("hello"),
593 "expected 'hello' in output: {stdout}"
594 );
595 assert!(output.status.success());
596 }
597
598 #[test]
599 fn wait_with_limits_truncates_large_output() {
600 let child = std::process::Command::new("sh")
602 .args(["-c", "yes 'aaaa' | head -25000"])
603 .stdout(std::process::Stdio::piped())
604 .stderr(std::process::Stdio::piped())
605 .spawn()
606 .unwrap();
607
608 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
609 let stdout = String::from_utf8_lossy(&output.stdout);
610 assert!(
611 stdout.contains("[lean-ctx: output truncated"),
612 "expected truncation notice, got len={}: ...{}",
613 stdout.len(),
614 &stdout[stdout.len().saturating_sub(80)..]
615 );
616 }
617
618 #[test]
619 fn wait_with_limits_timeout_kills_process() {
620 let child = std::process::Command::new("sleep")
621 .arg("60")
622 .stdout(std::process::Stdio::piped())
623 .stderr(std::process::Stdio::piped())
624 .spawn()
625 .unwrap();
626
627 let start = std::time::Instant::now();
628 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
629 let elapsed = start.elapsed();
630
631 assert!(
632 elapsed < std::time::Duration::from_secs(3),
633 "timeout should kill quickly, took {elapsed:?}"
634 );
635 let stdout = String::from_utf8_lossy(&output.stdout);
636 assert!(stdout.contains("[lean-ctx: output truncated"));
637 }
638
639 #[test]
640 fn heavy_commands_get_higher_limits() {
641 let (bytes, timeout) = super::exec_limits("cargo build --release");
642 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
643 assert_eq!(timeout, super::HEAVY_TIMEOUT);
644
645 let (bytes, timeout) = super::exec_limits("cargo test --lib");
646 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
647 assert_eq!(timeout, super::HEAVY_TIMEOUT);
648
649 let (bytes, timeout) = super::exec_limits("cargo nextest run");
650 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
651 assert_eq!(timeout, super::HEAVY_TIMEOUT);
652
653 let (bytes, timeout) = super::exec_limits("npm run build");
654 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
655 assert_eq!(timeout, super::HEAVY_TIMEOUT);
656
657 let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
658 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
659 assert_eq!(timeout, super::HEAVY_TIMEOUT);
660 }
661
662 #[test]
663 fn normal_commands_get_default_limits() {
664 let (bytes, timeout) = super::exec_limits("echo hello");
665 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
666 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
667
668 let (bytes, timeout) = super::exec_limits("git status");
669 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
670 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
671 }
672
673 #[test]
674 fn heavy_timeout_some_for_heavy_none_otherwise() {
675 assert_eq!(
676 super::heavy_timeout("cargo install --path ."),
677 Some(super::HEAVY_TIMEOUT)
678 );
679 assert_eq!(
680 super::heavy_timeout("cargo nextest run"),
681 Some(super::HEAVY_TIMEOUT)
682 );
683 assert_eq!(super::heavy_timeout("git status"), None);
684 assert_eq!(super::heavy_timeout("ls -la"), None);
685 }
686
687 #[test]
689 fn allowlist_enforces_in_hook_child_mode() {
690 assert!(super::allowlist_must_enforce_inner(true, false, true));
692 assert!(super::allowlist_must_enforce_inner(true, true, true));
693 }
694
695 #[test]
696 fn allowlist_enforces_for_non_interactive_callers() {
697 assert!(super::allowlist_must_enforce_inner(false, false, false));
699 }
700
701 #[test]
702 fn allowlist_warns_for_interactive_humans() {
703 assert!(!super::allowlist_must_enforce_inner(false, false, true));
705 }
706
707 #[test]
708 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
709 assert!(!super::allowlist_must_enforce_inner(false, true, false));
711 assert!(super::allowlist_must_enforce_inner(true, true, false));
712 }
713}