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 match tempfile::Builder::new()
313 .prefix("lean-ctx-ps-")
314 .suffix(".ps1")
315 .tempfile()
316 {
317 Ok(tmp) => {
318 let tmp_path = tmp.into_temp_path();
319 let _ = std::fs::write(&tmp_path, &ps_script);
320 cmd.args([
321 "-NoProfile",
322 "-ExecutionPolicy",
323 "Bypass",
324 "-File",
325 &tmp_path.to_string_lossy(),
326 ]);
327 ps_tmp_path = Some(tmp_path);
328 }
329 Err(e) => {
330 tracing::warn!(
331 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
332 );
333 cmd.arg(shell_flag);
334 cmd.arg(command);
335 ps_tmp_path = None;
336 }
337 }
338 } else {
339 cmd.arg(shell_flag);
340 cmd.arg(command);
341 ps_tmp_path = None;
342 }
343 }
344 #[cfg(not(windows))]
345 {
346 cmd.arg(shell_flag);
347 cmd.arg(command);
348 }
349
350 cmd.env("LEAN_CTX_ACTIVE", "1")
351 .stdout(Stdio::piped())
352 .stderr(Stdio::piped());
353 super::platform::apply_utf8_locale(&mut cmd);
354 let child = cmd.spawn();
355
356 let child = match child {
357 Ok(c) => c,
358 Err(e) => {
359 tracing::error!("lean-ctx: failed to execute: {e}");
360 #[cfg(windows)]
361 if let Some(ref tmp) = ps_tmp_path {
362 let _ = std::fs::remove_file(tmp);
363 }
364 return 127;
365 }
366 };
367
368 let (max_bytes, timeout) = exec_limits(command);
369 let output = wait_with_limits(child, max_bytes, timeout);
370
371 let duration_ms = start.elapsed().as_millis();
372 let exit_code = output.status.code().unwrap_or(1);
373 let stdout = super::platform::decode_output(&output.stdout);
374 let stderr = super::platform::decode_output(&output.stderr);
375
376 let full_output = combine_output(&stdout, &stderr);
377 let input_tokens = count_tokens(&full_output);
378
379 let (compressed, output_tokens) =
380 super::compress::compress_and_measure(command, &stdout, &stderr);
381
382 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
383
384 if !compressed.is_empty() {
385 let _ = io::stdout().write_all(compressed.as_bytes());
386 if !compressed.ends_with('\n') {
387 let _ = io::stdout().write_all(b"\n");
388 }
389 }
390 let should_tee = match cfg.tee_mode {
391 config::TeeMode::Always => !full_output.trim().is_empty(),
392 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
393 config::TeeMode::HighCompression => {
394 let orig = full_output.len();
395 let after = compressed.len();
396 let pct = if orig > 0 {
397 ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
398 } else {
399 0.0
400 };
401 pct > 70.0 && orig > 100
402 }
403 config::TeeMode::Never => false,
404 };
405 if should_tee {
406 if let Some(path) = super::redact::save_tee(command, &full_output) {
407 if !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
408 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
409 }
410 }
411 }
412
413 let threshold = cfg.slow_command_threshold_ms;
414 if threshold > 0 && duration_ms >= threshold as u128 {
415 slow_log::record(command, duration_ms, exit_code);
416 }
417
418 #[cfg(windows)]
419 if let Some(ref tmp) = ps_tmp_path {
420 let _ = std::fs::remove_file(tmp);
421 }
422
423 exit_code
424}
425
426#[cfg(test)]
427mod exec_tests {
428 #[test]
429 fn exec_direct_runs_true() {
430 let code = super::exec_direct(&["true".to_string()]);
431 assert_eq!(code, 0);
432 }
433
434 #[test]
435 fn exec_direct_runs_false() {
436 let code = super::exec_direct(&["false".to_string()]);
437 assert_ne!(code, 0);
438 }
439
440 #[test]
441 fn exec_direct_preserves_args_with_special_chars() {
442 let code = super::exec_direct(&[
443 "echo".to_string(),
444 "hello world".to_string(),
445 "it's here".to_string(),
446 "a \"quoted\" thing".to_string(),
447 ]);
448 assert_eq!(code, 0);
449 }
450
451 #[test]
452 fn exec_direct_nonexistent_returns_127() {
453 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
454 assert_eq!(code, 127);
455 }
456
457 #[test]
458 fn exec_argv_empty_returns_127() {
459 let code = super::exec_argv(&[]);
460 assert_eq!(code, 127);
461 }
462
463 #[test]
464 fn exec_argv_runs_simple_command() {
465 let code = super::exec_argv(&["true".to_string()]);
466 assert_eq!(code, 0);
467 }
468
469 #[test]
470 fn exec_argv_passes_through_when_disabled() {
471 std::env::set_var("LEAN_CTX_DISABLED", "1");
472 let code = super::exec_argv(&["true".to_string()]);
473 std::env::remove_var("LEAN_CTX_DISABLED");
474 assert_eq!(code, 0);
475 }
476
477 #[test]
478 fn wait_with_limits_captures_output() {
479 let child = std::process::Command::new("echo")
480 .arg("hello")
481 .stdout(std::process::Stdio::piped())
482 .stderr(std::process::Stdio::piped())
483 .spawn()
484 .unwrap();
485
486 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
487 let stdout = String::from_utf8_lossy(&output.stdout);
488 assert!(
489 stdout.contains("hello"),
490 "expected 'hello' in output: {stdout}"
491 );
492 assert!(output.status.success());
493 }
494
495 #[test]
496 fn wait_with_limits_truncates_large_output() {
497 let child = std::process::Command::new("sh")
499 .args(["-c", "yes 'aaaa' | head -25000"])
500 .stdout(std::process::Stdio::piped())
501 .stderr(std::process::Stdio::piped())
502 .spawn()
503 .unwrap();
504
505 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
506 let stdout = String::from_utf8_lossy(&output.stdout);
507 assert!(
508 stdout.contains("[lean-ctx: output truncated"),
509 "expected truncation notice, got len={}: ...{}",
510 stdout.len(),
511 &stdout[stdout.len().saturating_sub(80)..]
512 );
513 }
514
515 #[test]
516 fn wait_with_limits_timeout_kills_process() {
517 let child = std::process::Command::new("sleep")
518 .arg("60")
519 .stdout(std::process::Stdio::piped())
520 .stderr(std::process::Stdio::piped())
521 .spawn()
522 .unwrap();
523
524 let start = std::time::Instant::now();
525 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
526 let elapsed = start.elapsed();
527
528 assert!(
529 elapsed < std::time::Duration::from_secs(3),
530 "timeout should kill quickly, took {elapsed:?}"
531 );
532 let stdout = String::from_utf8_lossy(&output.stdout);
533 assert!(stdout.contains("[lean-ctx: output truncated"));
534 }
535
536 #[test]
537 fn heavy_commands_get_higher_limits() {
538 let (bytes, timeout) = super::exec_limits("cargo build --release");
539 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
540 assert_eq!(timeout, super::HEAVY_TIMEOUT);
541
542 let (bytes, timeout) = super::exec_limits("cargo test --lib");
543 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
544 assert_eq!(timeout, super::HEAVY_TIMEOUT);
545
546 let (bytes, timeout) = super::exec_limits("npm run build");
547 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
548 assert_eq!(timeout, super::HEAVY_TIMEOUT);
549
550 let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
551 assert_eq!(bytes, super::HEAVY_MAX_BYTES);
552 assert_eq!(timeout, super::HEAVY_TIMEOUT);
553 }
554
555 #[test]
556 fn normal_commands_get_default_limits() {
557 let (bytes, timeout) = super::exec_limits("echo hello");
558 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
559 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
560
561 let (bytes, timeout) = super::exec_limits("git status");
562 assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
563 assert_eq!(timeout, super::DEFAULT_TIMEOUT);
564 }
565}