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
209pub fn exec(command: &str) -> i32 {
210 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
211 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
212 }
213
214 let (shell, shell_flag) = super::platform::shell_and_flag();
215 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
216 let command = command.as_str();
217
218 if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
219 return exec_inherit(command, &shell, &shell_flag);
220 }
221
222 let cfg = config::Config::load();
223 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
224 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
225
226 if raw_mode {
227 return exec_inherit_tracked(command, &shell, &shell_flag);
228 }
229
230 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
231
232 if policy == super::output_policy::OutputPolicy::Passthrough {
234 return exec_inherit_tracked(command, &shell, &shell_flag);
235 }
236
237 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
241 return exec_inherit_tracked(command, &shell, &shell_flag);
242 }
243
244 if !force_compress {
245 if io::stdout().is_terminal() {
246 return exec_inherit_tracked(command, &shell, &shell_flag);
247 }
248 let code = exec_inherit(command, &shell, &shell_flag);
249 crate::core::tool_lifecycle::record_shell_command(0, 0);
250 return code;
251 }
252
253 exec_buffered(command, &shell, &shell_flag, &cfg)
254}
255
256fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
257 let mut cmd = Command::new(shell);
258 cmd.arg(shell_flag)
259 .arg(command)
260 .env("LEAN_CTX_ACTIVE", "1")
261 .stdin(Stdio::inherit())
262 .stdout(Stdio::inherit())
263 .stderr(Stdio::inherit());
264 super::platform::apply_utf8_locale(&mut cmd);
265 let status = cmd.status();
266
267 match status {
268 Ok(s) => s.code().unwrap_or(1),
269 Err(e) => {
270 tracing::error!("lean-ctx: failed to execute: {e}");
271 127
272 }
273 }
274}
275
276fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
277 let code = exec_inherit(command, shell, shell_flag);
278 crate::core::tool_lifecycle::record_shell_command(0, 0);
279 code
280}
281
282fn combine_output(stdout: &str, stderr: &str) -> String {
283 if stderr.is_empty() {
284 stdout.to_string()
285 } else if stdout.is_empty() {
286 stderr.to_string()
287 } else {
288 format!("{stdout}\n{stderr}")
289 }
290}
291
292fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
293 #[cfg(windows)]
294 super::platform::set_console_utf8();
295
296 let start = std::time::Instant::now();
297
298 let mut cmd = Command::new(shell);
299
300 #[cfg(windows)]
301 let ps_tmp_path: Option<tempfile::TempPath>;
302 #[cfg(windows)]
303 {
304 if super::platform::is_powershell(shell) {
305 let ps_script = format!(
306 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
307 command
308 );
309 let tmp = tempfile::Builder::new()
310 .prefix("lean-ctx-ps-")
311 .suffix(".ps1")
312 .tempfile()
313 .expect("failed to create temp file for PowerShell script");
314 let tmp_path = tmp.into_temp_path();
315 let _ = std::fs::write(&tmp_path, &ps_script);
316 cmd.args([
317 "-NoProfile",
318 "-ExecutionPolicy",
319 "Bypass",
320 "-File",
321 &tmp_path.to_string_lossy(),
322 ]);
323 ps_tmp_path = Some(tmp_path);
324 } else {
325 cmd.arg(shell_flag);
326 cmd.arg(command);
327 ps_tmp_path = None;
328 }
329 }
330 #[cfg(not(windows))]
331 {
332 cmd.arg(shell_flag);
333 cmd.arg(command);
334 }
335
336 cmd.env("LEAN_CTX_ACTIVE", "1")
337 .stdout(Stdio::piped())
338 .stderr(Stdio::piped());
339 super::platform::apply_utf8_locale(&mut cmd);
340 let child = cmd.spawn();
341
342 let child = match child {
343 Ok(c) => c,
344 Err(e) => {
345 tracing::error!("lean-ctx: failed to execute: {e}");
346 #[cfg(windows)]
347 if let Some(ref tmp) = ps_tmp_path {
348 let _ = std::fs::remove_file(tmp);
349 }
350 return 127;
351 }
352 };
353
354 let (max_bytes, timeout) = exec_limits(command);
355 let output = wait_with_limits(child, max_bytes, timeout);
356
357 let duration_ms = start.elapsed().as_millis();
358 let exit_code = output.status.code().unwrap_or(1);
359 let stdout = super::platform::decode_output(&output.stdout);
360 let stderr = super::platform::decode_output(&output.stderr);
361
362 let full_output = combine_output(&stdout, &stderr);
363 let input_tokens = count_tokens(&full_output);
364
365 let (compressed, output_tokens) =
366 super::compress::compress_and_measure(command, &stdout, &stderr);
367
368 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
369
370 if !compressed.is_empty() {
371 let _ = io::stdout().write_all(compressed.as_bytes());
372 if !compressed.ends_with('\n') {
373 let _ = io::stdout().write_all(b"\n");
374 }
375 }
376 let should_tee = match cfg.tee_mode {
377 config::TeeMode::Always => !full_output.trim().is_empty(),
378 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
379 config::TeeMode::HighCompression => {
380 let orig = full_output.len();
381 let after = compressed.len();
382 let pct = if orig > 0 {
383 ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
384 } else {
385 0.0
386 };
387 pct > 70.0 && orig > 100
388 }
389 config::TeeMode::Never => false,
390 };
391 if should_tee {
392 if let Some(path) = super::redact::save_tee(command, &full_output) {
393 if !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
394 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
395 }
396 }
397 }
398
399 let threshold = cfg.slow_command_threshold_ms;
400 if threshold > 0 && duration_ms >= threshold as u128 {
401 slow_log::record(command, duration_ms, exit_code);
402 }
403
404 #[cfg(windows)]
405 if let Some(ref tmp) = ps_tmp_path {
406 let _ = std::fs::remove_file(tmp);
407 }
408
409 exit_code
410}
411
412#[cfg(test)]
413mod exec_tests {
414 #[test]
415 fn exec_direct_runs_true() {
416 let code = super::exec_direct(&["true".to_string()]);
417 assert_eq!(code, 0);
418 }
419
420 #[test]
421 fn exec_direct_runs_false() {
422 let code = super::exec_direct(&["false".to_string()]);
423 assert_ne!(code, 0);
424 }
425
426 #[test]
427 fn exec_direct_preserves_args_with_special_chars() {
428 let code = super::exec_direct(&[
429 "echo".to_string(),
430 "hello world".to_string(),
431 "it's here".to_string(),
432 "a \"quoted\" thing".to_string(),
433 ]);
434 assert_eq!(code, 0);
435 }
436
437 #[test]
438 fn exec_direct_nonexistent_returns_127() {
439 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
440 assert_eq!(code, 127);
441 }
442
443 #[test]
444 fn exec_argv_empty_returns_127() {
445 let code = super::exec_argv(&[]);
446 assert_eq!(code, 127);
447 }
448
449 #[test]
450 fn exec_argv_runs_simple_command() {
451 let code = super::exec_argv(&["true".to_string()]);
452 assert_eq!(code, 0);
453 }
454
455 #[test]
456 fn exec_argv_passes_through_when_disabled() {
457 std::env::set_var("LEAN_CTX_DISABLED", "1");
458 let code = super::exec_argv(&["true".to_string()]);
459 std::env::remove_var("LEAN_CTX_DISABLED");
460 assert_eq!(code, 0);
461 }
462
463 #[test]
464 fn wait_with_limits_captures_output() {
465 let child = std::process::Command::new("echo")
466 .arg("hello")
467 .stdout(std::process::Stdio::piped())
468 .stderr(std::process::Stdio::piped())
469 .spawn()
470 .unwrap();
471
472 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
473 let stdout = String::from_utf8_lossy(&output.stdout);
474 assert!(
475 stdout.contains("hello"),
476 "expected 'hello' in output: {stdout}"
477 );
478 assert!(output.status.success());
479 }
480
481 #[test]
482 fn wait_with_limits_truncates_large_output() {
483 let child = std::process::Command::new("sh")
485 .args(["-c", "yes 'aaaa' | head -25000"])
486 .stdout(std::process::Stdio::piped())
487 .stderr(std::process::Stdio::piped())
488 .spawn()
489 .unwrap();
490
491 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
492 let stdout = String::from_utf8_lossy(&output.stdout);
493 assert!(
494 stdout.contains("[lean-ctx: output truncated"),
495 "expected truncation notice, got len={}: ...{}",
496 stdout.len(),
497 &stdout[stdout.len().saturating_sub(80)..]
498 );
499 }
500
501 #[test]
502 fn wait_with_limits_timeout_kills_process() {
503 let child = std::process::Command::new("sleep")
504 .arg("60")
505 .stdout(std::process::Stdio::piped())
506 .stderr(std::process::Stdio::piped())
507 .spawn()
508 .unwrap();
509
510 let start = std::time::Instant::now();
511 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
512 let elapsed = start.elapsed();
513
514 assert!(
515 elapsed < std::time::Duration::from_secs(3),
516 "timeout should kill quickly, took {elapsed:?}"
517 );
518 let stdout = String::from_utf8_lossy(&output.stdout);
519 assert!(stdout.contains("[lean-ctx: output truncated"));
520 }
521
522 #[test]
523 fn heavy_commands_get_higher_limits() {
524 let (bytes, timeout) = super::exec_limits("cargo build --release");
525 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
526 assert_eq!(timeout, super::HEAVY_TIMEOUT);
527
528 let (bytes, timeout) = super::exec_limits("cargo test --lib");
529 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
530 assert_eq!(timeout, super::HEAVY_TIMEOUT);
531
532 let (bytes, timeout) = super::exec_limits("npm run build");
533 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
534 assert_eq!(timeout, super::HEAVY_TIMEOUT);
535
536 let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
537 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
538 assert_eq!(timeout, super::HEAVY_TIMEOUT);
539 }
540
541 #[test]
542 fn normal_commands_get_default_limits() {
543 let (bytes, timeout) = super::exec_limits("echo hello");
544 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
545 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
546
547 let (bytes, timeout) = super::exec_limits("git status");
548 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
549 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
550 }
551}