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