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            // A temp script lets us set UTF-8 output encoding. If the temp file
310            // cannot be created (full disk, perms, broken TMP), degrade to
311            // running the command inline rather than panicking the process.
312            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        // Generate ~100 KB of output, limit to 1 KB
498        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}