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
103#[cfg(test)]
104mod nested_lean_ctx_exec_tests {
105    #[test]
106    fn collapses_single_nested_c() {
107        assert_eq!(
108            super::collapse_nested_lean_ctx_exec("lean-ctx -c 'git status'").as_deref(),
109            Some("git status")
110        );
111    }
112
113    #[test]
114    fn collapses_repeated_nested_c() {
115        assert_eq!(
116            super::collapse_nested_lean_ctx_exec("lean-ctx -c 'lean-ctx -c \"git status\"'")
117                .as_deref(),
118            Some("git status")
119        );
120    }
121
122    #[test]
123    fn preserves_inner_shell_quoting() {
124        assert_eq!(
125            super::collapse_nested_lean_ctx_exec("lean-ctx -c \"git commit -m 'hello world'\"")
126                .as_deref(),
127            Some("git commit -m 'hello world'")
128        );
129        assert_eq!(
130            super::collapse_nested_lean_ctx_exec("lean-ctx -c git commit -m 'hello world'")
131                .as_deref(),
132            Some("git commit -m 'hello world'")
133        );
134    }
135
136    #[test]
137    fn collapses_exec_alias_and_path() {
138        assert_eq!(
139            super::collapse_nested_lean_ctx_exec("/usr/local/bin/lean-ctx exec 'git status'")
140                .as_deref(),
141            Some("git status")
142        );
143    }
144
145    #[test]
146    fn leaves_non_wrappers_alone() {
147        assert!(super::collapse_nested_lean_ctx_exec("git status").is_none());
148    }
149
150    #[test]
151    fn wrapped_nested_wrapper_still_owns_one_compression_pass() {
152        let _lock = crate::core::data_dir::test_env_lock();
153        crate::test_env::set_var(super::super::reentry::WRAP_MARKER, "1");
154
155        assert!(super::should_delegate_wrapped_to_shell_default(false));
156        assert!(
157            !super::should_delegate_wrapped_to_shell_default(true),
158            "collapsed nested wrappers must not fall through to raw shell-default path"
159        );
160
161        crate::test_env::remove_var(super::super::reentry::WRAP_MARKER);
162    }
163}
164
165const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; // 8 MB
166const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
167const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MB
168const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
169
170fn exec_limits(command: &str) -> (usize, std::time::Duration) {
171    let max_bytes = if is_heavy_command(command) {
172        HEAVY_MAX_BYTES
173    } else {
174        DEFAULT_MAX_BYTES
175    };
176    (max_bytes, shell_timeout(command))
177}
178
179/// Resolve the timeout `ctx_shell` / the shell hook grants a command.
180///
181/// Heavy builds/tests (cargo install/nextest/build, npm ci, git commit/push, …)
182/// get the long ceiling instead of being killed at the 2-minute default, keeping
183/// the MCP path and the interactive hook consistent. The constants are
184/// overridable so operators can pin any value. Precedence (first match wins):
185///
186/// 1. `LEAN_CTX_SHELL_TIMEOUT_MS` — universal override, in milliseconds.
187/// 2. heavy command → `LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS` / config
188///    `shell_heavy_timeout_secs`, else [`HEAVY_TIMEOUT`].
189/// 3. normal command → `LEAN_CTX_SHELL_TIMEOUT_SECS` / config
190///    `shell_timeout_secs`, else [`DEFAULT_TIMEOUT`].
191#[must_use]
192pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
193    if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
194        return std::time::Duration::from_millis(ms);
195    }
196    if is_heavy_command(command) {
197        if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
198            .or_else(|| config::Config::load().shell_heavy_timeout_secs)
199        {
200            return std::time::Duration::from_secs(secs);
201        }
202        HEAVY_TIMEOUT
203    } else {
204        if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
205            .or_else(|| config::Config::load().shell_timeout_secs)
206        {
207            return std::time::Duration::from_secs(secs);
208        }
209        DEFAULT_TIMEOUT
210    }
211}
212
213/// Parse a positive `u64` from an env var, ignoring absent/empty/zero/invalid
214/// values so the caller falls through to the next precedence tier.
215fn env_u64(var: &str) -> Option<u64> {
216    std::env::var(var)
217        .ok()
218        .and_then(|v| v.parse::<u64>().ok())
219        .filter(|n| *n > 0)
220}
221
222fn is_heavy_command(command: &str) -> bool {
223    let cmd = command.trim();
224    let lower = cmd.to_lowercase();
225    static HEAVY_PREFIXES: &[&str] = &[
226        "cargo build",
227        "cargo test",
228        "cargo nextest",
229        "cargo clippy",
230        "cargo check",
231        "cargo install",
232        "cargo bench",
233        "npm run build",
234        "npm install",
235        "npm ci",
236        "pnpm install",
237        "pnpm build",
238        "yarn install",
239        "yarn build",
240        "bun install",
241        "make",
242        "cmake",
243        "bazel build",
244        "bazel test",
245        "gradle build",
246        "gradle test",
247        "mvn package",
248        "mvn install",
249        "mvn test",
250        "go build",
251        "go test",
252        "dotnet build",
253        "dotnet test",
254        "swift build",
255        "swift test",
256        "flutter build",
257        "docker build",
258        "docker compose build",
259        "pip install",
260        "poetry install",
261        "uv sync",
262        "bundle install",
263        "mix compile",
264        // Git commands that fire build/test hooks: a `pre-commit` running
265        // `cargo clippy` or a `pre-push` running a full preflight can take
266        // minutes, far past the 2-minute default. Killing git mid-hook leaves
267        // the working tree staged-but-uncommitted and the push half-done, so
268        // these get the heavy ceiling. `git status`/`log`/`diff` stay default
269        // because the prefix is the full `git <verb>`.
270        "git commit",
271        "git push",
272    ];
273    HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
274}
275
276/// Execute a command from pre-split argv without going through `sh -c`.
277/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
278/// already correctly split by the user's shell, so re-serializing them
279/// into a string and re-parsing via `sh -c` would risk mangling complex
280/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
281pub fn exec_argv(args: &[String]) -> i32 {
282    if args.is_empty() {
283        return 127;
284    }
285
286    // Quote-safe join used only for the allowlist/policy *checks*; execution
287    // below still consumes the pre-split argv verbatim (the whole reason `-t`
288    // avoids `sh -c`). Joining first means a single argv element such as
289    // `git status; rm -rf /` is checked as ONE quoted token, never re-parsed.
290    let joined = super::platform::join_command(args);
291
292    // #595: unwrap a host command wrapper (eval + cwd snapshot) before any
293    // checks so the real command — not the wrapper — is gated and run. The `-t`
294    // path cannot exec a compound argv, so route the rebuild through `exec`.
295    if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
296        return exec(&u.rebuild());
297    }
298
299    // The `-t` track path is the agent's default shell hook
300    // (`_lc() { lean-ctx -t "$@" }`), so it MUST enforce the same allowlist
301    // boundary as `-c` (see `exec`). Previously it skipped the check entirely,
302    // letting every aliased multi-arg invocation (`_lc git …`) bypass the
303    // restriction that `lean-ctx -c` enforces (GH security audit, finding 1).
304    if let Some(code) = allowlist_gate(&joined) {
305        return code;
306    }
307
308    if super::reentry::should_pass_through() {
309        return exec_direct(args);
310    }
311
312    let cfg = config::Config::load();
313    let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
314
315    if policy.is_protected() {
316        let code = exec_direct(args);
317        crate::core::tool_lifecycle::record_shell_command(0, 0);
318        return code;
319    }
320
321    let code = exec_direct(args);
322    crate::core::tool_lifecycle::record_shell_command(0, 0);
323    code
324}
325
326fn exec_direct(args: &[String]) -> i32 {
327    let mut cmd = Command::new(&args[0]);
328    cmd.args(&args[1..])
329        .stdin(Stdio::inherit())
330        .stdout(Stdio::inherit())
331        .stderr(Stdio::inherit());
332    super::reentry::mark_child(&mut cmd);
333    super::platform::apply_utf8_locale(&mut cmd);
334    let status = cmd.status();
335
336    match status {
337        Ok(s) => s.code().unwrap_or(1),
338        Err(e) => {
339            tracing::error!("lean-ctx: failed to execute: {e}");
340            127
341        }
342    }
343}
344
345/// Decides whether an allowlist violation on the CLI path blocks (exit 126) or
346/// only warns.
347///
348/// Enforced when:
349/// - hook-child mode (`LEAN_CTX_HOOK_CHILD`): lean-ctx is the agent's
350///   command-interception channel and must not be weaker than the MCP path, or
351/// - stderr is not a TTY: a non-interactive caller is an agent or script, and
352///   agent-driven `lean-ctx -c` must enforce the same boundary as ctx_shell.
353///
354/// Warn-only when a human runs `lean-ctx -c` at an interactive terminal (they
355/// can run the command without lean-ctx anyway, so blocking adds friction, not
356/// a boundary) or when `LEAN_CTX_ALLOWLIST_WARN_ONLY=1` explicitly opts out.
357fn allowlist_must_enforce() -> bool {
358    let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
359    let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
360        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
361    allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
362}
363
364/// Pure decision core of [`allowlist_must_enforce`] (unit-testable without
365/// process-global env/TTY state).
366fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
367    if hook_child {
368        return true;
369    }
370    if warn_only {
371        return false;
372    }
373    !stderr_is_tty
374}
375
376/// True when this process's stdout is a **regular file** — i.e. the caller
377/// redirected output to a file (`cmd > out`, `cmd >> out`).
378///
379/// Output captured to a file is consumed as *data*, so it must stay byte-faithful:
380/// compression would silently drop/abbreviate lines and corrupt the file
381/// (e.g. `git status --short > files.txt` losing entries). Pipes (agent capture)
382/// and TTYs are NOT regular files and return `false`, so they keep their normal
383/// behavior — this only ever *adds* a verbatim guarantee, never removes one.
384///
385/// Uses only `std`: it wraps the existing stdout descriptor in a `ManuallyDrop`
386/// `File` purely to read its metadata (`fstat` on Unix, `GetFileInformation` on
387/// Windows) without ever closing the real stdout.
388fn stdout_is_regular_file() -> bool {
389    #[cfg(unix)]
390    {
391        use std::os::unix::io::{AsRawFd, FromRawFd};
392        let fd = io::stdout().as_raw_fd();
393        // SAFETY: fd 1 stays valid for the whole process. `ManuallyDrop` prevents
394        // the wrapper's `Drop` from closing stdout; we only read metadata.
395        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
396        file.metadata().is_ok_and(|m| m.is_file())
397    }
398    #[cfg(windows)]
399    {
400        use std::os::windows::io::{AsRawHandle, FromRawHandle};
401        let handle = io::stdout().as_raw_handle();
402        // SAFETY: the stdout handle stays valid for the whole process.
403        // `ManuallyDrop` prevents the wrapper's `Drop` from closing it.
404        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
405        file.metadata().is_ok_and(|m| m.is_file())
406    }
407    #[cfg(not(any(unix, windows)))]
408    {
409        false
410    }
411}
412
413/// Shared allowlist gate for the CLI shell entrypoints — `-c` (via [`exec`]) and
414/// `-t` (via [`exec_argv`]). Both must apply the SAME boundary so the track path
415/// (the default shell hook) cannot be weaker than the compress path.
416///
417/// Returns `Some(126)` when the command is blocked and the caller must return
418/// that exit code; `None` when execution may proceed (allowed, or warn-only for
419/// an interactive human — see [`allowlist_must_enforce`]).
420fn allowlist_gate(command: &str) -> Option<i32> {
421    if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
422        if allowlist_must_enforce() {
423            eprintln!("{msg}");
424            eprintln!(
425                "lean-ctx: command blocked by shell allowlist. \
426                 Allow it permanently: lean-ctx allow <cmd> — or set \
427                 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
428            );
429            return Some(126);
430        }
431        tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
432    }
433    None
434}
435
436pub fn exec(command: &str) -> i32 {
437    // #595: when the agent wraps its command in host scaffolding
438    // (`… && eval '<cmd>' … && pwd -P >| …-cwd`), look through it so the allowlist
439    // and compression act on the REAL command, not the wrapper — whose `eval` the
440    // allowlist would otherwise hard-block on every single call. The cwd snapshot
441    // is preserved so the host keeps tracking the working directory.
442    let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
443    let mut collapsed_nested = false;
444    let collapsed;
445    let command = unwrapped.as_deref().unwrap_or(command);
446    let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
447        collapsed_nested = true;
448        collapsed = c;
449        collapsed.as_str()
450    } else {
451        command
452    };
453
454    if let Some(code) = allowlist_gate(command) {
455        return code;
456    }
457
458    let (shell, shell_flag) = super::platform::shell_and_flag();
459    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
460    let command = command.as_str();
461
462    if super::reentry::is_disabled() {
463        return exec_inherit(command, &shell, &shell_flag);
464    }
465    if should_delegate_wrapped_to_shell_default(collapsed_nested) {
466        return exec_shell_default(command, &shell, &shell_flag);
467    }
468
469    let cfg = config::Config::load();
470    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
471    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
472
473    if raw_mode {
474        return exec_inherit_tracked(command, &shell, &shell_flag);
475    }
476
477    let policy = super::output_policy::classify(command, &cfg.excluded_commands);
478
479    // Passthrough: ALWAYS bypass compression, even with force_compress.
480    if policy == super::output_policy::OutputPolicy::Passthrough {
481        return exec_inherit_tracked(command, &shell, &shell_flag);
482    }
483
484    // Verbatim: bypass compression unless force_compress is set,
485    // in which case use buffered path (compress_if_beneficial will
486    // respect the verbatim classification and only size-cap).
487    if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
488        return exec_inherit_tracked(command, &shell, &shell_flag);
489    }
490
491    if !force_compress {
492        if io::stdout().is_terminal() {
493            return exec_inherit_tracked(command, &shell, &shell_flag);
494        }
495        let code = exec_inherit(command, &shell, &shell_flag);
496        crate::core::tool_lifecycle::record_shell_command(0, 0);
497        return code;
498    }
499
500    // Compression is forced (`-c` / LEAN_CTX_COMPRESS, e.g. the agent shell hook).
501    // It must STILL never alter bytes destined for a file: a redirect
502    // (`cmd > out`, `cmd >> out`) means the output is captured as data, not read by
503    // a human or agent. Writing the compressed digest there would silently
504    // drop/abbreviate lines and corrupt the file (e.g. contradictory `git diff`
505    // dumps). Pass redirected-to-file output through verbatim; pipes (agent
506    // capture) and TTYs keep compressing. This is the single choke point, so it
507    // holds for every caller (hook, direct CLI, Pi/MCP bridges).
508    if stdout_is_regular_file() {
509        return exec_inherit_tracked(command, &shell, &shell_flag);
510    }
511
512    exec_buffered(command, &shell, &shell_flag, &cfg)
513}
514
515fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
516    let mut current = command.trim().to_string();
517    let mut changed = false;
518
519    while let Some(next) = strip_one_lean_ctx_exec(&current) {
520        if next == current {
521            break;
522        }
523        current = next;
524        changed = true;
525    }
526
527    changed.then_some(current)
528}
529
530fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
531    // After collapsing `lean-ctx -c "lean-ctx -c ..."` the current process is the
532    // one compression pass that would otherwise be owned by the shell default.
533    // Delegating again would drop back to raw execution or re-enter the hook.
534    super::reentry::is_wrapped() && !collapsed_nested
535}
536
537fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
538    let words = split_simple_shell_words(command)?;
539    if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
540        return None;
541    }
542    if words[1].value != "-c" && words[1].value != "exec" {
543        return None;
544    }
545    if words[2..].iter().any(|w| {
546        matches!(
547            w.value.as_str(),
548            "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
549        )
550    }) {
551        return None;
552    }
553    if words.len() == 3 {
554        Some(words[2].value.trim().to_string())
555    } else {
556        Some(command[words[2].start..].trim().to_string())
557    }
558}
559
560fn is_lean_ctx_bin(word: &str) -> bool {
561    std::path::Path::new(word)
562        .file_name()
563        .and_then(|name| name.to_str())
564        .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
565}
566
567struct SimpleShellWord {
568    value: String,
569    start: usize,
570}
571
572fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
573    let mut words = Vec::new();
574    let mut current = String::new();
575    let mut current_start: Option<usize> = None;
576    let mut chars = command.char_indices().peekable();
577    let mut quote: Option<char> = None;
578
579    while let Some((idx, ch)) = chars.next() {
580        match quote {
581            Some('\'') if ch == '\'' => quote = None,
582            Some('"') if ch == '"' => quote = None,
583            None if ch == '\'' || ch == '"' => {
584                current_start.get_or_insert(idx);
585                quote = Some(ch);
586            }
587            Some('"') | None if ch == '\\' => {
588                current_start.get_or_insert(idx);
589                if let Some((_, next)) = chars.next() {
590                    current.push(next);
591                }
592            }
593            None if ch.is_whitespace() => {
594                if let Some(start) = current_start.take() {
595                    words.push(SimpleShellWord {
596                        value: std::mem::take(&mut current),
597                        start,
598                    });
599                }
600            }
601            Some(_) | None => {
602                current_start.get_or_insert(idx);
603                current.push(ch);
604            }
605        }
606    }
607
608    if quote.is_some() {
609        return None;
610    }
611    if let Some(start) = current_start {
612        words.push(SimpleShellWord {
613            value: current,
614            start,
615        });
616    }
617    (!words.is_empty()).then_some(words)
618}
619
620fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
621    let mut cmd = Command::new(shell);
622    cmd.arg(shell_flag)
623        .arg(command)
624        .stdin(Stdio::inherit())
625        .stdout(Stdio::inherit())
626        .stderr(Stdio::inherit());
627    super::reentry::mark_child(&mut cmd);
628    super::platform::apply_utf8_locale(&mut cmd);
629    super::platform::apply_profile_free_env(&mut cmd);
630    let status = cmd.status();
631
632    match status {
633        Ok(s) => s.code().unwrap_or(1),
634        Err(e) => {
635            tracing::error!("lean-ctx: failed to execute: {e}");
636            127
637        }
638    }
639}
640
641fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
642    let mut cmd = Command::new(shell);
643    cmd.arg(shell_flag)
644        .arg(command)
645        .stdin(Stdio::inherit())
646        .stdout(Stdio::inherit())
647        .stderr(Stdio::inherit());
648    super::reentry::clear_shell_default_markers(&mut cmd);
649    super::platform::apply_utf8_locale(&mut cmd);
650    super::platform::apply_profile_free_env(&mut cmd);
651    let status = cmd.status();
652
653    match status {
654        Ok(s) => s.code().unwrap_or(1),
655        Err(e) => {
656            eprintln!("lean-ctx: failed to execute '{command}': {e}");
657            127
658        }
659    }
660}
661
662fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
663    let code = exec_inherit(command, shell, shell_flag);
664    crate::core::tool_lifecycle::record_shell_command(0, 0);
665    code
666}
667
668/// Label inserted between stdout and stderr of a FAILED command so the agent can
669/// attribute the error to the right stream instead of guessing — and never has to
670/// re-run the command raw just to locate the failure. See #809 / #812.
671pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
672
673/// Join captured stdout and stderr for display/recovery. On failure (non-zero
674/// exit) with both streams present, a labeled delimiter separates them; success
675/// output keeps the plain `stdout\nstderr` shape (determinism, #498).
676pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
677    match (stdout.is_empty(), stderr.is_empty()) {
678        (_, true) => stdout.to_string(),
679        (true, false) => stderr.to_string(),
680        (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
681        (false, false) => format!("{stdout}\n{stderr}"),
682    }
683}
684
685fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
686    #[cfg(windows)]
687    super::platform::set_console_utf8();
688
689    let start = std::time::Instant::now();
690
691    let mut cmd = Command::new(shell);
692
693    #[cfg(windows)]
694    let ps_tmp_path: Option<tempfile::TempPath>;
695    #[cfg(windows)]
696    {
697        if super::platform::is_powershell(shell) {
698            let ps_script = format!(
699                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
700                command
701            );
702            // A temp script lets us set UTF-8 output encoding. If the temp file
703            // cannot be created (full disk, perms, broken TMP), degrade to
704            // running the command inline rather than panicking the process.
705            match tempfile::Builder::new()
706                .prefix("lean-ctx-ps-")
707                .suffix(".ps1")
708                .tempfile()
709            {
710                Ok(tmp) => {
711                    let tmp_path = tmp.into_temp_path();
712                    let _ = std::fs::write(&tmp_path, &ps_script);
713                    cmd.args([
714                        "-NoProfile",
715                        "-ExecutionPolicy",
716                        "Bypass",
717                        "-File",
718                        &tmp_path.to_string_lossy(),
719                    ]);
720                    ps_tmp_path = Some(tmp_path);
721                }
722                Err(e) => {
723                    tracing::warn!(
724                        "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
725                    );
726                    cmd.arg(shell_flag);
727                    cmd.arg(command);
728                    ps_tmp_path = None;
729                }
730            }
731        } else {
732            cmd.arg(shell_flag);
733            cmd.arg(command);
734            ps_tmp_path = None;
735        }
736    }
737    #[cfg(not(windows))]
738    {
739        cmd.arg(shell_flag);
740        cmd.arg(command);
741    }
742
743    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
744    super::reentry::mark_child(&mut cmd);
745    super::platform::apply_utf8_locale(&mut cmd);
746    super::platform::apply_profile_free_env(&mut cmd);
747    let child = cmd.spawn();
748
749    let child = match child {
750        Ok(c) => c,
751        Err(e) => {
752            tracing::error!("lean-ctx: failed to execute: {e}");
753            #[cfg(windows)]
754            if let Some(ref tmp) = ps_tmp_path {
755                let _ = std::fs::remove_file(tmp);
756            }
757            return 127;
758        }
759    };
760
761    let (max_bytes, timeout) = exec_limits(command);
762    let output = wait_with_limits(child, max_bytes, timeout);
763
764    let duration_ms = start.elapsed().as_millis();
765    let exit_code = output.status.code().unwrap_or(1);
766    let stdout = super::platform::decode_output(&output.stdout);
767    let stderr = super::platform::decode_output(&output.stderr);
768
769    let full_output = combine_streams(&stdout, &stderr, exit_code);
770    let input_tokens = count_tokens(&full_output);
771
772    // Structured diagnostics (#499): failing cargo/tsc/eslint runs mark their
773    // files as context-priority; succeeding runs clear them.
774    crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
775
776    // Gotcha learning: a failing build/test pushes a pending error; the next
777    // green run of the same command base correlates the fix into a gotcha.
778    crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
779
780    let (compressed, output_tokens) =
781        super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
782
783    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
784
785    if !compressed.is_empty() {
786        let _ = io::stdout().write_all(compressed.as_bytes());
787        if !compressed.ends_with('\n') {
788            let _ = io::stdout().write_all(b"\n");
789        }
790    }
791    // Shared tee policy (#811): identical decision on the CLI and MCP paths —
792    // `Failures` keys off the real exit code, not a substring in the output.
793    let should_tee = super::tee_policy::should_tee(
794        &cfg.tee_mode,
795        exit_code,
796        full_output.trim().is_empty(),
797        input_tokens,
798        output_tokens,
799    );
800    if should_tee
801        && let Some(path) = super::redact::save_tee(command, &full_output)
802        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
803    {
804        eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
805    }
806
807    let threshold = cfg.slow_command_threshold_ms;
808    if threshold > 0 && duration_ms >= threshold as u128 {
809        slow_log::record(command, duration_ms, exit_code);
810    }
811
812    #[cfg(windows)]
813    if let Some(ref tmp) = ps_tmp_path {
814        let _ = std::fs::remove_file(tmp);
815    }
816
817    exit_code
818}
819
820#[cfg(test)]
821mod exec_tests {
822    #[test]
823    fn combine_streams_labels_stderr_on_failure() {
824        let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
825        assert_eq!(
826            out,
827            format!(
828                "build ok\n{}\nlinker: undefined symbol",
829                super::STDERR_LABEL
830            )
831        );
832    }
833
834    #[test]
835    fn combine_streams_plain_join_on_success() {
836        let out = super::combine_streams("step 1", "warning: noop", 0);
837        assert_eq!(out, "step 1\nwarning: noop");
838        assert!(!out.contains(super::STDERR_LABEL));
839    }
840
841    #[test]
842    fn combine_streams_single_stream_is_unchanged() {
843        assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
844        assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
845    }
846
847    #[test]
848    fn exec_direct_runs_true() {
849        let code = super::exec_direct(&["true".to_string()]);
850        assert_eq!(code, 0);
851    }
852
853    #[test]
854    fn exec_direct_runs_false() {
855        let code = super::exec_direct(&["false".to_string()]);
856        assert_ne!(code, 0);
857    }
858
859    #[test]
860    fn exec_direct_preserves_args_with_special_chars() {
861        let code = super::exec_direct(&[
862            "echo".to_string(),
863            "hello world".to_string(),
864            "it's here".to_string(),
865            "a \"quoted\" thing".to_string(),
866        ]);
867        assert_eq!(code, 0);
868    }
869
870    #[test]
871    fn exec_direct_nonexistent_returns_127() {
872        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
873        assert_eq!(code, 127);
874    }
875
876    #[test]
877    fn exec_argv_empty_returns_127() {
878        let code = super::exec_argv(&[]);
879        assert_eq!(code, 127);
880    }
881
882    #[test]
883    fn exec_argv_runs_simple_command() {
884        let _lock = crate::core::data_dir::test_env_lock();
885        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
886        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
887        let code = super::exec_argv(&["true".to_string()]);
888        assert_eq!(code, 0);
889    }
890
891    #[test]
892    fn exec_argv_passes_through_when_disabled() {
893        let _lock = crate::core::data_dir::test_env_lock();
894        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
895        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
896        let code = super::exec_argv(&["true".to_string()]);
897        crate::test_env::remove_var("LEAN_CTX_DISABLED");
898        assert_eq!(code, 0);
899    }
900
901    // Finding 1 (GH security audit): the `-t` track path is the default shell
902    // hook, so it must enforce the allowlist exactly like the `-c` path. A
903    // non-allowlisted command must be blocked (126), not executed.
904    #[test]
905    fn exec_argv_enforces_allowlist_for_disallowed_command() {
906        let _lock = crate::core::data_dir::test_env_lock();
907        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
908        crate::test_env::remove_var("LEAN_CTX_DISABLED");
909        crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
910        // hook-child forces enforcement regardless of the test runner's TTY state.
911        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
912        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
913
914        let code = super::exec_argv(&["true".to_string()]);
915
916        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
917        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
918
919        assert_eq!(
920            code, 126,
921            "non-allowlisted command must be blocked on the -t track path"
922        );
923    }
924
925    #[test]
926    fn exec_argv_allows_allowlisted_command() {
927        let _lock = crate::core::data_dir::test_env_lock();
928        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
929        crate::test_env::remove_var("LEAN_CTX_DISABLED");
930        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
931        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
932
933        let code = super::exec_argv(&["true".to_string()]);
934
935        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
936        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
937
938        assert_eq!(code, 0, "allowlisted command must run on the -t track path");
939    }
940
941    #[test]
942    fn wait_with_limits_captures_output() {
943        let child = std::process::Command::new("echo")
944            .arg("hello")
945            .stdout(std::process::Stdio::piped())
946            .stderr(std::process::Stdio::piped())
947            .spawn()
948            .unwrap();
949
950        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
951        let stdout = String::from_utf8_lossy(&output.stdout);
952        assert!(
953            stdout.contains("hello"),
954            "expected 'hello' in output: {stdout}"
955        );
956        assert!(output.status.success());
957    }
958
959    #[test]
960    fn wait_with_limits_truncates_large_output() {
961        // Generate ~100 KB of output, limit to 1 KB
962        let child = std::process::Command::new("sh")
963            .args(["-c", "yes 'aaaa' | head -25000"])
964            .stdout(std::process::Stdio::piped())
965            .stderr(std::process::Stdio::piped())
966            .spawn()
967            .unwrap();
968
969        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
970        let stdout = String::from_utf8_lossy(&output.stdout);
971        assert!(
972            stdout.contains("[lean-ctx: output truncated"),
973            "expected truncation notice, got len={}: ...{}",
974            stdout.len(),
975            &stdout[stdout.len().saturating_sub(80)..]
976        );
977    }
978
979    #[test]
980    fn wait_with_limits_timeout_kills_process() {
981        let child = std::process::Command::new("sleep")
982            .arg("60")
983            .stdout(std::process::Stdio::piped())
984            .stderr(std::process::Stdio::piped())
985            .spawn()
986            .unwrap();
987
988        let start = std::time::Instant::now();
989        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
990        let elapsed = start.elapsed();
991
992        assert!(
993            elapsed < std::time::Duration::from_secs(3),
994            "timeout should kill quickly, took {elapsed:?}"
995        );
996        let stdout = String::from_utf8_lossy(&output.stdout);
997        assert!(stdout.contains("[lean-ctx: output truncated"));
998    }
999
1000    #[test]
1001    fn heavy_commands_get_higher_byte_limits() {
1002        // exec_limits owns the byte ceiling; timeout resolution is covered by
1003        // `shell_timeout_resolves_heavy_normal_and_env_overrides` (which is
1004        // env/config-isolated, so these stay deterministic regardless of the
1005        // operator's config.toml).
1006        for cmd in [
1007            "cargo build --release",
1008            "cargo test --lib",
1009            "cargo nextest run",
1010            "npm run build",
1011            "docker build -t myapp .",
1012            // Git verbs that fire build/test hooks (pre-commit clippy, pre-push
1013            // preflight) must not be killed at the default ceiling (#854).
1014            "git commit --amend --no-edit",
1015            "git push -u origin HEAD",
1016        ] {
1017            let (bytes, _) = super::exec_limits(cmd);
1018            assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1019        }
1020    }
1021
1022    #[test]
1023    fn normal_commands_get_default_byte_limits() {
1024        // Read-only git verbs stay on the default ceiling — only `commit`/`push`
1025        // (which fire the cargo-heavy hooks) are promoted.
1026        for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1027            let (bytes, _) = super::exec_limits(cmd);
1028            assert_eq!(
1029                bytes,
1030                super::DEFAULT_MAX_BYTES,
1031                "default byte limit for {cmd}"
1032            );
1033        }
1034    }
1035
1036    #[test]
1037    fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1038        // Serialize env mutation so this never races other env-reading tests.
1039        let _lock = crate::core::data_dir::test_env_lock();
1040        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1041        let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1042        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1043        for v in [
1044            "LEAN_CTX_SHELL_TIMEOUT_MS",
1045            "LEAN_CTX_SHELL_TIMEOUT_SECS",
1046            "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1047        ] {
1048            crate::test_env::remove_var(v);
1049        }
1050
1051        // Heavy builds/tests and hook-firing git verbs get the heavy ceiling;
1052        // read-only verbs stay on the default. Preserves the #854 promotion.
1053        assert_eq!(
1054            super::shell_timeout("cargo install --path ."),
1055            super::HEAVY_TIMEOUT
1056        );
1057        assert_eq!(
1058            super::shell_timeout("cargo nextest run"),
1059            super::HEAVY_TIMEOUT
1060        );
1061        assert_eq!(
1062            super::shell_timeout("git commit -m 'wip'"),
1063            super::HEAVY_TIMEOUT
1064        );
1065        assert_eq!(
1066            super::shell_timeout("git push origin main"),
1067            super::HEAVY_TIMEOUT
1068        );
1069        assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1070        assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1071
1072        // Per-tier env overrides win over the built-in constants. (Non-round
1073        // second values keep the literals clippy-clean and unambiguous.)
1074        crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1075        assert_eq!(
1076            super::shell_timeout("cargo build"),
1077            std::time::Duration::from_secs(90)
1078        );
1079        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1080
1081        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1082        assert_eq!(
1083            super::shell_timeout("git status"),
1084            std::time::Duration::from_secs(30)
1085        );
1086        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1087
1088        // The universal millisecond override wins over everything.
1089        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1090        assert_eq!(
1091            super::shell_timeout("cargo build"),
1092            std::time::Duration::from_secs(5)
1093        );
1094        assert_eq!(
1095            super::shell_timeout("git status"),
1096            std::time::Duration::from_secs(5)
1097        );
1098        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1099
1100        for (var, saved) in [
1101            ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1102            ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1103            ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1104        ] {
1105            if let Some(v) = saved {
1106                crate::test_env::set_var(var, v);
1107            }
1108        }
1109    }
1110
1111    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
1112    #[test]
1113    fn allowlist_enforces_in_hook_child_mode() {
1114        // Hook-child wins over everything, even an interactive TTY.
1115        assert!(super::allowlist_must_enforce_inner(true, false, true));
1116        assert!(super::allowlist_must_enforce_inner(true, true, true));
1117    }
1118
1119    #[test]
1120    fn allowlist_enforces_for_non_interactive_callers() {
1121        // Agent/script invocation: stderr is a pipe → enforce.
1122        assert!(super::allowlist_must_enforce_inner(false, false, false));
1123    }
1124
1125    #[test]
1126    fn allowlist_warns_for_interactive_humans() {
1127        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
1128        assert!(!super::allowlist_must_enforce_inner(false, false, true));
1129    }
1130
1131    #[test]
1132    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1133        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
1134        assert!(!super::allowlist_must_enforce_inner(false, true, false));
1135        assert!(super::allowlist_must_enforce_inner(true, true, false));
1136    }
1137}