Skip to main content

lean_ctx/shell/
exec.rs

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
8/// Wait for a child process with output-size and time limits.
9/// Kills the process if either limit is exceeded, returning what was
10/// captured so far. Prevents unbounded memory growth on commands that
11/// produce massive output (e.g. `rg -i "pattern"` over a large tree).
12fn 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; // 8 MB
104const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
105const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MB
106const 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
161/// Execute a command from pre-split argv without going through `sh -c`.
162/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
163/// already correctly split by the user's shell, so re-serializing them
164/// into a string and re-parsing via `sh -c` would risk mangling complex
165/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
166pub 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    // Passthrough: ALWAYS bypass compression, even with force_compress.
233    if policy == super::output_policy::OutputPolicy::Passthrough {
234        return exec_inherit_tracked(command, &shell, &shell_flag);
235    }
236
237    // Verbatim: bypass compression unless force_compress is set,
238    // in which case use buffered path (compress_if_beneficial will
239    // respect the verbatim classification and only size-cap).
240    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        // Generate ~100 KB of output, limit to 1 KB
484        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}