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