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