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).
12///
13/// `kill_group` (Unix): the child was spawned into its own process group
14/// (`process_group(0)`), so a timeout kill signals the whole group. Killing
15/// only the direct child (a shell) leaves orphaned grandchildren holding the
16/// stdout/stderr pipe write ends — the reader threads then never see EOF and
17/// the join below blocks forever, wedging the caller *despite* the timeout
18/// having fired (GH #720: an orphaned `rg` kept a Cursor shell session dead
19/// for hours).
20fn wait_with_limits(
21    mut child: Child,
22    max_bytes: usize,
23    timeout: std::time::Duration,
24    kill_group: bool,
25) -> Output {
26    let stdout_pipe = child.stdout.take();
27    let stderr_pipe = child.stderr.take();
28    let start = std::time::Instant::now();
29
30    let stdout_handle = std::thread::spawn(move || {
31        let Some(mut pipe) = stdout_pipe else {
32            return (Vec::new(), false);
33        };
34        let mut buf = Vec::with_capacity(max_bytes.min(64 * 1024));
35        let mut chunk = [0u8; 8192];
36        loop {
37            match pipe.read(&mut chunk) {
38                Ok(0) => break,
39                Ok(n) => {
40                    if buf.len() + n > max_bytes {
41                        let remaining = max_bytes.saturating_sub(buf.len());
42                        buf.extend_from_slice(&chunk[..remaining]);
43                        return (buf, true);
44                    }
45                    buf.extend_from_slice(&chunk[..n]);
46                }
47                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
48                Err(_) => break,
49            }
50        }
51        (buf, false)
52    });
53
54    let stderr_handle = std::thread::spawn(move || {
55        let Some(mut pipe) = stderr_pipe else {
56            return Vec::new();
57        };
58        let mut buf = Vec::new();
59        let mut chunk = [0u8; 4096];
60        const STDERR_LIMIT: usize = 512 * 1024;
61        loop {
62            match pipe.read(&mut chunk) {
63                Ok(0) => break,
64                Ok(n) => {
65                    if buf.len() + n > STDERR_LIMIT {
66                        break;
67                    }
68                    buf.extend_from_slice(&chunk[..n]);
69                }
70                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
71                Err(_) => break,
72            }
73        }
74        buf
75    });
76
77    let mut timed_out = false;
78    loop {
79        if start.elapsed() > timeout {
80            kill_child(&mut child, kill_group);
81            let _ = child.wait();
82            timed_out = true;
83            break;
84        }
85        match child.try_wait() {
86            Ok(Some(_)) | Err(_) => break,
87            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
88        }
89    }
90
91    let (mut stdout_buf, stdout_truncated) = stdout_handle.join().unwrap_or_default();
92    let stderr_buf = stderr_handle.join().unwrap_or_default();
93
94    if timed_out || stdout_truncated {
95        let notice = format!(
96            "\n[lean-ctx: output truncated at {} MB / {}s limit]\n",
97            max_bytes / (1024 * 1024),
98            timeout.as_secs()
99        );
100        stdout_buf.extend_from_slice(notice.as_bytes());
101    }
102
103    let status = child.wait().unwrap_or_else(|_| {
104        std::process::Command::new("false")
105            .status()
106            .expect("cannot run `false`")
107    });
108
109    Output {
110        status,
111        stdout: stdout_buf,
112        stderr: stderr_buf,
113    }
114}
115
116/// Kill a timed-out child — and, when it owns a process group, every
117/// descendant in that group (GH #720). SIGKILL to the negative pgid reaps
118/// shells' grandchildren so the captured pipes actually close.
119fn kill_child(child: &mut Child, kill_group: bool) {
120    #[cfg(unix)]
121    if kill_group {
122        let pgid = child.id() as libc::pid_t;
123        if pgid > 0 {
124            // SAFETY: plain syscall; a stale pgid at worst returns ESRCH.
125            unsafe { libc::killpg(pgid, libc::SIGKILL) };
126        }
127    }
128    #[cfg(not(unix))]
129    let _ = kill_group;
130    let _ = child.kill();
131}
132
133#[cfg(test)]
134mod nested_lean_ctx_exec_tests {
135    #[test]
136    fn collapses_single_nested_c() {
137        assert_eq!(
138            super::collapse_nested_lean_ctx_exec("lean-ctx -c 'git status'").as_deref(),
139            Some("git status")
140        );
141    }
142
143    #[test]
144    fn collapses_repeated_nested_c() {
145        assert_eq!(
146            super::collapse_nested_lean_ctx_exec("lean-ctx -c 'lean-ctx -c \"git status\"'")
147                .as_deref(),
148            Some("git status")
149        );
150    }
151
152    #[test]
153    fn preserves_inner_shell_quoting() {
154        assert_eq!(
155            super::collapse_nested_lean_ctx_exec("lean-ctx -c \"git commit -m 'hello world'\"")
156                .as_deref(),
157            Some("git commit -m 'hello world'")
158        );
159        assert_eq!(
160            super::collapse_nested_lean_ctx_exec("lean-ctx -c git commit -m 'hello world'")
161                .as_deref(),
162            Some("git commit -m 'hello world'")
163        );
164    }
165
166    #[test]
167    fn collapses_exec_alias_and_path() {
168        assert_eq!(
169            super::collapse_nested_lean_ctx_exec("/usr/local/bin/lean-ctx exec 'git status'")
170                .as_deref(),
171            Some("git status")
172        );
173    }
174
175    #[test]
176    fn leaves_non_wrappers_alone() {
177        assert!(super::collapse_nested_lean_ctx_exec("git status").is_none());
178    }
179
180    #[test]
181    fn wrapped_nested_wrapper_still_owns_one_compression_pass() {
182        let _lock = crate::core::data_dir::test_env_lock();
183        crate::test_env::set_var(super::super::reentry::WRAP_MARKER, "1");
184
185        assert!(super::should_delegate_wrapped_to_shell_default(false));
186        assert!(
187            !super::should_delegate_wrapped_to_shell_default(true),
188            "collapsed nested wrappers must not fall through to raw shell-default path"
189        );
190
191        crate::test_env::remove_var(super::super::reentry::WRAP_MARKER);
192    }
193}
194
195const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; // 8 MB
196const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
197const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MB
198const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
199
200fn exec_limits(command: &str) -> (usize, std::time::Duration) {
201    let max_bytes = if is_heavy_command(command) {
202        HEAVY_MAX_BYTES
203    } else {
204        DEFAULT_MAX_BYTES
205    };
206    (max_bytes, shell_timeout(command))
207}
208
209/// Resolve the timeout `ctx_shell` / the shell hook grants a command.
210///
211/// Heavy builds/tests (cargo install/nextest/build, npm ci, git commit/push, …)
212/// get the long ceiling instead of being killed at the 2-minute default, keeping
213/// the MCP path and the interactive hook consistent. The constants are
214/// overridable so operators can pin any value. Precedence (first match wins):
215///
216/// 1. `LEAN_CTX_SHELL_TIMEOUT_MS` — universal override, in milliseconds.
217/// 2. heavy command → `LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS` / config
218///    `shell_heavy_timeout_secs`, else [`HEAVY_TIMEOUT`].
219/// 3. normal command → `LEAN_CTX_SHELL_TIMEOUT_SECS` / config
220///    `shell_timeout_secs`, else [`DEFAULT_TIMEOUT`].
221#[must_use]
222pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
223    shell_timeout_with_override(command, None)
224}
225
226/// Hard ceiling for a per-call `timeout_ms` override: generous enough for any
227/// legitimate build/release job, low enough that a typo'd value cannot wedge
228/// the executor for days.
229const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000; // 1 hour
230
231/// [`shell_timeout`] with an optional per-call override (ctx_shell's
232/// `timeout_ms` arg). Precedence: operator env pin (`LEAN_CTX_SHELL_TIMEOUT_MS`)
233/// > per-call override (clamped to [`MAX_CALL_TIMEOUT_MS`], zero ignored)
234/// > per-tier env/config > built-in heavy/normal ceilings.
235#[must_use]
236pub(crate) fn shell_timeout_with_override(
237    command: &str,
238    override_ms: Option<u64>,
239) -> std::time::Duration {
240    if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
241        return std::time::Duration::from_millis(ms);
242    }
243    if let Some(ms) = override_ms.filter(|n| *n > 0) {
244        return std::time::Duration::from_millis(ms.min(MAX_CALL_TIMEOUT_MS));
245    }
246    if is_heavy_command(command) {
247        if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
248            .or_else(|| config::Config::load().shell_heavy_timeout_secs)
249        {
250            return std::time::Duration::from_secs(secs);
251        }
252        HEAVY_TIMEOUT
253    } else {
254        if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
255            .or_else(|| config::Config::load().shell_timeout_secs)
256        {
257            return std::time::Duration::from_secs(secs);
258        }
259        DEFAULT_TIMEOUT
260    }
261}
262
263/// Parse a positive `u64` from an env var, ignoring absent/empty/zero/invalid
264/// values so the caller falls through to the next precedence tier.
265fn env_u64(var: &str) -> Option<u64> {
266    std::env::var(var)
267        .ok()
268        .and_then(|v| v.parse::<u64>().ok())
269        .filter(|n| *n > 0)
270}
271
272fn is_heavy_command(command: &str) -> bool {
273    let cmd = command.trim();
274    let lower = cmd.to_lowercase();
275    static HEAVY_PREFIXES: &[&str] = &[
276        "cargo build",
277        "cargo test",
278        "cargo nextest",
279        "cargo clippy",
280        "cargo check",
281        "cargo install",
282        "cargo bench",
283        "npm run build",
284        "npm install",
285        "npm ci",
286        "pnpm install",
287        "pnpm build",
288        "yarn install",
289        "yarn build",
290        "bun install",
291        "make",
292        "cmake",
293        "bazel build",
294        "bazel test",
295        "gradle build",
296        "gradle test",
297        "mvn package",
298        "mvn install",
299        "mvn test",
300        "go build",
301        "go test",
302        "dotnet build",
303        "dotnet test",
304        "swift build",
305        "swift test",
306        "flutter build",
307        "docker build",
308        "docker compose build",
309        "pip install",
310        "poetry install",
311        "uv sync",
312        "bundle install",
313        "mix compile",
314        // Git commands that fire build/test hooks: a `pre-commit` running
315        // `cargo clippy` or a `pre-push` running a full preflight can take
316        // minutes, far past the 2-minute default. Killing git mid-hook leaves
317        // the working tree staged-but-uncommitted and the push half-done, so
318        // these get the heavy ceiling. `git status`/`log`/`diff` stay default
319        // because the prefix is the full `git <verb>`.
320        "git commit",
321        "git push",
322        // Task runners wrap builds/test gates; the underlying job is what's
323        // heavy, so the wrapper gets the same ceiling. A fast subcommand
324        // (`mise ls`) merely inherits a longer kill deadline — harmless.
325        "mise ",
326        "just ",
327    ];
328
329    let matches_heavy = |s: &str| HEAVY_PREFIXES.iter().any(|p| s.starts_with(p));
330
331    if matches_heavy(&lower) {
332        return true;
333    }
334
335    // Agents often prefix commands with `cd /path && ...` or `cd /path;`.
336    // Extract the final segment after the last `&&` or `;` and check that too.
337    let final_cmd = lower
338        .rsplit_once("&&")
339        .or_else(|| lower.rsplit_once(';'))
340        .map_or("", |(_, rhs)| rhs.trim());
341
342    !final_cmd.is_empty() && matches_heavy(final_cmd)
343}
344
345/// Execute a command from pre-split argv without going through `sh -c`.
346/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
347/// already correctly split by the user's shell, so re-serializing them
348/// into a string and re-parsing via `sh -c` would risk mangling complex
349/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
350pub fn exec_argv(args: &[String]) -> i32 {
351    if args.is_empty() {
352        return 127;
353    }
354
355    // Quote-safe join used only for the allowlist/policy *checks*; execution
356    // below still consumes the pre-split argv verbatim (the whole reason `-t`
357    // avoids `sh -c`). Joining first means a single argv element such as
358    // `git status; rm -rf /` is checked as ONE quoted token, never re-parsed.
359    let joined = super::platform::join_command(args);
360
361    // #595: unwrap a host command wrapper (eval + cwd snapshot) before any
362    // checks so the real command — not the wrapper — is gated and run. The `-t`
363    // path cannot exec a compound argv, so route the rebuild through `exec`.
364    if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
365        return exec(&u.rebuild());
366    }
367
368    // The `-t` track path is the agent's default shell hook
369    // (`_lc() { lean-ctx -t "$@" }`), so it MUST enforce the same allowlist
370    // boundary as `-c` (see `exec`). Previously it skipped the check entirely,
371    // letting every aliased multi-arg invocation (`_lc git …`) bypass the
372    // restriction that `lean-ctx -c` enforces (GH security audit, finding 1).
373    if let Some(code) = allowlist_gate(&joined) {
374        return code;
375    }
376
377    if super::reentry::should_pass_through() {
378        return exec_direct(args);
379    }
380
381    let cfg = config::Config::load();
382    let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
383
384    if policy.is_protected() {
385        let code = exec_direct(args);
386        crate::core::tool_lifecycle::record_shell_command(0, 0);
387        return code;
388    }
389
390    let code = exec_direct(args);
391    crate::core::tool_lifecycle::record_shell_command(0, 0);
392    code
393}
394
395fn exec_direct(args: &[String]) -> i32 {
396    let mut cmd = Command::new(&args[0]);
397    cmd.args(&args[1..])
398        .stdin(Stdio::inherit())
399        .stdout(Stdio::inherit())
400        .stderr(Stdio::inherit());
401    super::reentry::mark_child(&mut cmd);
402    super::platform::apply_utf8_locale(&mut cmd);
403    let status = cmd.status();
404
405    match status {
406        Ok(s) => s.code().unwrap_or(1),
407        Err(e) => {
408            tracing::error!("lean-ctx: failed to execute: {e}");
409            127
410        }
411    }
412}
413
414/// Decides whether an allowlist violation on the CLI path blocks (exit 126) or
415/// only warns.
416///
417/// Enforced when:
418/// - hook-child mode (`LEAN_CTX_HOOK_CHILD`): lean-ctx is the agent's
419///   command-interception channel and must not be weaker than the MCP path, or
420/// - stderr is not a TTY: a non-interactive caller is an agent or script, and
421///   agent-driven `lean-ctx -c` must enforce the same boundary as ctx_shell.
422///
423/// Warn-only when a human runs `lean-ctx -c` at an interactive terminal (they
424/// can run the command without lean-ctx anyway, so blocking adds friction, not
425/// a boundary) or when `LEAN_CTX_ALLOWLIST_WARN_ONLY=1` explicitly opts out.
426fn allowlist_must_enforce() -> bool {
427    let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
428    let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
429        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
430    allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
431}
432
433/// Pure decision core of [`allowlist_must_enforce`] (unit-testable without
434/// process-global env/TTY state).
435fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
436    if hook_child {
437        return true;
438    }
439    if warn_only {
440        return false;
441    }
442    !stderr_is_tty
443}
444
445/// True when this process's stdout is a **regular file** — i.e. the caller
446/// redirected output to a file (`cmd > out`, `cmd >> out`).
447///
448/// Output captured to a file is consumed as *data*, so it must stay byte-faithful:
449/// compression would silently drop/abbreviate lines and corrupt the file
450/// (e.g. `git status --short > files.txt` losing entries). Pipes (agent capture)
451/// and TTYs are NOT regular files and return `false`, so they keep their normal
452/// behavior — this only ever *adds* a verbatim guarantee, never removes one.
453///
454/// Uses only `std`: it wraps the existing stdout descriptor in a `ManuallyDrop`
455/// `File` purely to read its metadata (`fstat` on Unix, `GetFileInformation` on
456/// Windows) without ever closing the real stdout.
457fn stdout_is_regular_file() -> bool {
458    #[cfg(unix)]
459    {
460        use std::os::unix::io::{AsRawFd, FromRawFd};
461        let fd = io::stdout().as_raw_fd();
462        // SAFETY: fd 1 stays valid for the whole process. `ManuallyDrop` prevents
463        // the wrapper's `Drop` from closing stdout; we only read metadata.
464        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
465        file.metadata().is_ok_and(|m| m.is_file())
466    }
467    #[cfg(windows)]
468    {
469        use std::os::windows::io::{AsRawHandle, FromRawHandle};
470        let handle = io::stdout().as_raw_handle();
471        // SAFETY: the stdout handle stays valid for the whole process.
472        // `ManuallyDrop` prevents the wrapper's `Drop` from closing it.
473        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
474        file.metadata().is_ok_and(|m| m.is_file())
475    }
476    #[cfg(not(any(unix, windows)))]
477    {
478        false
479    }
480}
481
482/// Shared allowlist gate for the CLI shell entrypoints — `-c` (via [`exec`]) and
483/// `-t` (via [`exec_argv`]). Both must apply the SAME boundary so the track path
484/// (the default shell hook) cannot be weaker than the compress path.
485///
486/// Returns `Some(126)` when the command is blocked and the caller must return
487/// that exit code; `None` when execution may proceed (allowed, or warn-only for
488/// an interactive human — see [`allowlist_must_enforce`]).
489fn allowlist_gate(command: &str) -> Option<i32> {
490    if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
491        if allowlist_must_enforce() {
492            eprintln!("{msg}");
493            eprintln!(
494                "lean-ctx: command blocked by shell allowlist. \
495                 Allow it permanently: lean-ctx allow <cmd> — or set \
496                 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
497            );
498            return Some(126);
499        }
500        // Diagnostic, not user feedback: an interactive human at a TTY can run
501        // the command without lean-ctx anyway, and surfacing a WARN in their
502        // plain terminal is exactly the confusion GH #699 reported. Keep the
503        // warning for non-TTY callers (agents that opted into warn-only).
504        if io::stderr().is_terminal() {
505            tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
506        } else {
507            tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
508        }
509    }
510    None
511}
512
513pub fn exec(command: &str) -> i32 {
514    // #595: when the agent wraps its command in host scaffolding
515    // (`… && eval '<cmd>' … && pwd -P >| …-cwd`), look through it so the allowlist
516    // and compression act on the REAL command, not the wrapper — whose `eval` the
517    // allowlist would otherwise hard-block on every single call. The cwd snapshot
518    // is preserved so the host keeps tracking the working directory.
519    let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
520    let mut collapsed_nested = false;
521    let collapsed;
522    let command = unwrapped.as_deref().unwrap_or(command);
523    let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
524        collapsed_nested = true;
525        collapsed = c;
526        collapsed.as_str()
527    } else {
528        command
529    };
530
531    if let Some(code) = allowlist_gate(command) {
532        return code;
533    }
534
535    let (shell, shell_flag) = super::platform::shell_and_flag();
536    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
537    let command = command.as_str();
538
539    if super::reentry::is_disabled() {
540        return exec_inherit(command, &shell, &shell_flag);
541    }
542    if should_delegate_wrapped_to_shell_default(collapsed_nested) {
543        return exec_shell_default(command, &shell, &shell_flag);
544    }
545
546    let cfg = config::Config::load();
547    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
548    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
549
550    if raw_mode {
551        return exec_inherit_tracked(command, &shell, &shell_flag);
552    }
553
554    let policy = super::output_policy::classify(command, &cfg.excluded_commands);
555
556    // Passthrough: ALWAYS bypass compression, even with force_compress.
557    if policy == super::output_policy::OutputPolicy::Passthrough {
558        return exec_inherit_tracked(command, &shell, &shell_flag);
559    }
560
561    // Verbatim: bypass compression unless force_compress is set,
562    // in which case use buffered path (compress_if_beneficial will
563    // respect the verbatim classification and only size-cap).
564    if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
565        return exec_inherit_tracked(command, &shell, &shell_flag);
566    }
567
568    if !force_compress {
569        if io::stdout().is_terminal() {
570            return exec_inherit_tracked(command, &shell, &shell_flag);
571        }
572        let code = exec_inherit(command, &shell, &shell_flag);
573        crate::core::tool_lifecycle::record_shell_command(0, 0);
574        return code;
575    }
576
577    // Compression is forced (`-c` / LEAN_CTX_COMPRESS, e.g. the agent shell hook).
578    // It must STILL never alter bytes destined for a file: a redirect
579    // (`cmd > out`, `cmd >> out`) means the output is captured as data, not read by
580    // a human or agent. Writing the compressed digest there would silently
581    // drop/abbreviate lines and corrupt the file (e.g. contradictory `git diff`
582    // dumps). Pass redirected-to-file output through verbatim; pipes (agent
583    // capture) and TTYs keep compressing. This is the single choke point, so it
584    // holds for every caller (hook, direct CLI, Pi/MCP bridges).
585    if stdout_is_regular_file() {
586        return exec_inherit_tracked(command, &shell, &shell_flag);
587    }
588
589    exec_buffered(command, &shell, &shell_flag, &cfg)
590}
591
592fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
593    let mut current = command.trim().to_string();
594    let mut changed = false;
595
596    while let Some(next) = strip_one_lean_ctx_exec(&current) {
597        if next == current {
598            break;
599        }
600        current = next;
601        changed = true;
602    }
603
604    changed.then_some(current)
605}
606
607fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
608    // After collapsing `lean-ctx -c "lean-ctx -c ..."` the current process is the
609    // one compression pass that would otherwise be owned by the shell default.
610    // Delegating again would drop back to raw execution or re-enter the hook.
611    super::reentry::is_wrapped() && !collapsed_nested
612}
613
614fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
615    let words = split_simple_shell_words(command)?;
616    if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
617        return None;
618    }
619    if words[1].value != "-c" && words[1].value != "exec" {
620        return None;
621    }
622    if words[2..].iter().any(|w| {
623        matches!(
624            w.value.as_str(),
625            "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
626        )
627    }) {
628        return None;
629    }
630    if words.len() == 3 {
631        Some(words[2].value.trim().to_string())
632    } else {
633        Some(command[words[2].start..].trim().to_string())
634    }
635}
636
637fn is_lean_ctx_bin(word: &str) -> bool {
638    std::path::Path::new(word)
639        .file_name()
640        .and_then(|name| name.to_str())
641        .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
642}
643
644struct SimpleShellWord {
645    value: String,
646    start: usize,
647}
648
649fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
650    let mut words = Vec::new();
651    let mut current = String::new();
652    let mut current_start: Option<usize> = None;
653    let mut chars = command.char_indices().peekable();
654    let mut quote: Option<char> = None;
655
656    while let Some((idx, ch)) = chars.next() {
657        match quote {
658            Some('\'') if ch == '\'' => quote = None,
659            Some('"') if ch == '"' => quote = None,
660            None if ch == '\'' || ch == '"' => {
661                current_start.get_or_insert(idx);
662                quote = Some(ch);
663            }
664            Some('"') | None if ch == '\\' => {
665                current_start.get_or_insert(idx);
666                if let Some((_, next)) = chars.next() {
667                    current.push(next);
668                }
669            }
670            None if ch.is_whitespace() => {
671                if let Some(start) = current_start.take() {
672                    words.push(SimpleShellWord {
673                        value: std::mem::take(&mut current),
674                        start,
675                    });
676                }
677            }
678            Some(_) | None => {
679                current_start.get_or_insert(idx);
680                current.push(ch);
681            }
682        }
683    }
684
685    if quote.is_some() {
686        return None;
687    }
688    if let Some(start) = current_start {
689        words.push(SimpleShellWord {
690            value: current,
691            start,
692        });
693    }
694    (!words.is_empty()).then_some(words)
695}
696
697fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
698    let mut cmd = Command::new(shell);
699    cmd.arg(shell_flag)
700        .arg(command)
701        .stdin(Stdio::inherit())
702        .stdout(Stdio::inherit())
703        .stderr(Stdio::inherit());
704    super::reentry::mark_child(&mut cmd);
705    super::platform::apply_utf8_locale(&mut cmd);
706    super::platform::apply_profile_free_env(&mut cmd);
707    let status = cmd.status();
708
709    match status {
710        Ok(s) => s.code().unwrap_or(1),
711        Err(e) => {
712            tracing::error!("lean-ctx: failed to execute: {e}");
713            127
714        }
715    }
716}
717
718fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
719    let mut cmd = Command::new(shell);
720    cmd.arg(shell_flag)
721        .arg(command)
722        .stdin(Stdio::inherit())
723        .stdout(Stdio::inherit())
724        .stderr(Stdio::inherit());
725    super::reentry::clear_shell_default_markers(&mut cmd);
726    super::platform::apply_utf8_locale(&mut cmd);
727    super::platform::apply_profile_free_env(&mut cmd);
728    let status = cmd.status();
729
730    match status {
731        Ok(s) => s.code().unwrap_or(1),
732        Err(e) => {
733            eprintln!("lean-ctx: failed to execute '{command}': {e}");
734            127
735        }
736    }
737}
738
739fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
740    let code = exec_inherit(command, shell, shell_flag);
741    crate::core::tool_lifecycle::record_shell_command(0, 0);
742    code
743}
744
745/// Label inserted between stdout and stderr of a FAILED command so the agent can
746/// attribute the error to the right stream instead of guessing — and never has to
747/// re-run the command raw just to locate the failure. See #809 / #812.
748pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
749
750/// Join captured stdout and stderr for display/recovery. On failure (non-zero
751/// exit) with both streams present, a labeled delimiter separates them; success
752/// output keeps the plain `stdout\nstderr` shape (determinism, #498).
753pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
754    match (stdout.is_empty(), stderr.is_empty()) {
755        (_, true) => stdout.to_string(),
756        (true, false) => stderr.to_string(),
757        (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
758        (false, false) => format!("{stdout}\n{stderr}"),
759    }
760}
761
762fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
763    #[cfg(windows)]
764    super::platform::set_console_utf8();
765
766    let start = std::time::Instant::now();
767
768    let mut cmd = Command::new(shell);
769
770    #[cfg(windows)]
771    let ps_tmp_path: Option<tempfile::TempPath>;
772    #[cfg(windows)]
773    {
774        if super::platform::is_powershell(shell) {
775            let ps_script = format!(
776                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
777                command
778            );
779            // A temp script lets us set UTF-8 output encoding. If the temp file
780            // cannot be created (full disk, perms, broken TMP), degrade to
781            // running the command inline rather than panicking the process.
782            match tempfile::Builder::new()
783                .prefix("lean-ctx-ps-")
784                .suffix(".ps1")
785                .tempfile()
786            {
787                Ok(tmp) => {
788                    let tmp_path = tmp.into_temp_path();
789                    let _ = std::fs::write(&tmp_path, &ps_script);
790                    cmd.args([
791                        "-NoProfile",
792                        "-ExecutionPolicy",
793                        "Bypass",
794                        "-File",
795                        &tmp_path.to_string_lossy(),
796                    ]);
797                    ps_tmp_path = Some(tmp_path);
798                }
799                Err(e) => {
800                    tracing::warn!(
801                        "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
802                    );
803                    cmd.arg(shell_flag);
804                    cmd.arg(command);
805                    ps_tmp_path = None;
806                }
807            }
808        } else {
809            cmd.arg(shell_flag);
810            cmd.arg(command);
811            ps_tmp_path = None;
812        }
813    }
814    #[cfg(not(windows))]
815    {
816        cmd.arg(shell_flag);
817        cmd.arg(command);
818    }
819
820    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
821    // #720: the buffered path serves agents and pipes — there is no
822    // interactive stdin to forward. Inheriting the host's stdin let
823    // stdin-reading commands (`rg` with no path after an empty `$(…)`
824    // substitution, `cat` without a file) block forever on a pipe that never
825    // delivers EOF, wedging the host's persistent shell session. /dev/null
826    // answers with EOF immediately. A real TTY stdin is preserved so
827    // interactive `lean-ctx -c` (prompts, sudo) keeps working — and only in
828    // the non-TTY case do we detach the child into its own process group,
829    // so the timeout kill can reap grandchildren without stealing Ctrl+C
830    // from interactive users.
831    let isolate = !io::stdin().is_terminal();
832    if isolate {
833        // #806: use Stdio::piped() instead of Stdio::null() so callers that
834        // legitimately pipe data (e.g. `printf 'prompt' | lean-ctx -c 'claude
835        // --print'`) can deliver it. A relay thread copies parent stdin →
836        // child stdin and propagates EOF. The #720 hang is still prevented by
837        // wait_with_limits' process-group timeout kill — not by nulling stdin.
838        cmd.stdin(Stdio::piped());
839        #[cfg(unix)]
840        {
841            use std::os::unix::process::CommandExt as _;
842            cmd.process_group(0);
843        }
844    }
845    super::reentry::mark_child(&mut cmd);
846    super::platform::apply_utf8_locale(&mut cmd);
847    super::platform::apply_profile_free_env(&mut cmd);
848    let child = cmd.spawn();
849
850    let mut child = match child {
851        Ok(c) => c,
852        Err(e) => {
853            tracing::error!("lean-ctx: failed to execute: {e}");
854            #[cfg(windows)]
855            if let Some(ref tmp) = ps_tmp_path {
856                let _ = std::fs::remove_file(tmp);
857            }
858            return 127;
859        }
860    };
861
862    // #806: stdin relay — forward parent stdin to the child's piped stdin.
863    // The thread exits when: (a) parent stdin reaches EOF (pipe closed), or
864    // (b) the child dies and the next write returns BrokenPipe.
865    // No explicit join: wait_with_limits returns → exec_buffered returns →
866    // process exits → OS reaps the relay thread.
867    if isolate && let Some(child_stdin) = child.stdin.take() {
868        std::thread::Builder::new()
869            .name("stdin-relay".into())
870            .spawn(move || {
871                use std::io::Write;
872                let mut child_w = child_stdin;
873                let mut parent_r = io::stdin().lock();
874                let mut buf = [0u8; 8192];
875                loop {
876                    match parent_r.read(&mut buf) {
877                        Ok(0) => break,
878                        Ok(n) => {
879                            if child_w.write_all(&buf[..n]).is_err() {
880                                break;
881                            }
882                        }
883                        Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
884                        Err(_) => break,
885                    }
886                }
887                drop(child_w);
888            })
889            .ok();
890    }
891
892    let (max_bytes, timeout) = exec_limits(command);
893    let output = wait_with_limits(child, max_bytes, timeout, isolate);
894
895    let duration_ms = start.elapsed().as_millis();
896    let exit_code = output.status.code().unwrap_or(1);
897    let stdout = super::platform::decode_output(&output.stdout);
898    let stderr = super::platform::decode_output(&output.stderr);
899
900    let full_output = combine_streams(&stdout, &stderr, exit_code);
901    let input_tokens = count_tokens(&full_output);
902
903    // Structured diagnostics (#499): failing cargo/tsc/eslint runs mark their
904    // files as context-priority; succeeding runs clear them.
905    crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
906
907    // Gotcha learning: a failing build/test pushes a pending error; the next
908    // green run of the same command base correlates the fix into a gotcha.
909    crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
910
911    let (compressed, output_tokens) =
912        super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
913
914    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
915
916    if !compressed.is_empty() {
917        let _ = io::stdout().write_all(compressed.as_bytes());
918        if !compressed.ends_with('\n') {
919            let _ = io::stdout().write_all(b"\n");
920        }
921    }
922    // Shared tee policy (#811): identical decision on the CLI and MCP paths —
923    // `Failures` keys off the real exit code, not a substring in the output.
924    let should_tee = super::tee_policy::should_tee(
925        &cfg.tee_mode,
926        exit_code,
927        full_output.trim().is_empty(),
928        input_tokens,
929        output_tokens,
930    );
931    if should_tee
932        && let Some(path) = super::redact::save_tee(command, &full_output)
933        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
934    {
935        eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
936    }
937
938    let threshold = cfg.slow_command_threshold_ms;
939    if threshold > 0 && duration_ms >= threshold as u128 {
940        slow_log::record(command, duration_ms, exit_code);
941    }
942
943    #[cfg(windows)]
944    if let Some(ref tmp) = ps_tmp_path {
945        let _ = std::fs::remove_file(tmp);
946    }
947
948    exit_code
949}
950
951#[cfg(test)]
952mod exec_tests {
953    #[test]
954    fn combine_streams_labels_stderr_on_failure() {
955        let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
956        assert_eq!(
957            out,
958            format!(
959                "build ok\n{}\nlinker: undefined symbol",
960                super::STDERR_LABEL
961            )
962        );
963    }
964
965    #[test]
966    fn combine_streams_plain_join_on_success() {
967        let out = super::combine_streams("step 1", "warning: noop", 0);
968        assert_eq!(out, "step 1\nwarning: noop");
969        assert!(!out.contains(super::STDERR_LABEL));
970    }
971
972    #[test]
973    fn combine_streams_single_stream_is_unchanged() {
974        assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
975        assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
976    }
977
978    #[test]
979    fn exec_direct_runs_true() {
980        let code = super::exec_direct(&["true".to_string()]);
981        assert_eq!(code, 0);
982    }
983
984    #[test]
985    fn exec_direct_runs_false() {
986        let code = super::exec_direct(&["false".to_string()]);
987        assert_ne!(code, 0);
988    }
989
990    #[test]
991    fn exec_direct_preserves_args_with_special_chars() {
992        let code = super::exec_direct(&[
993            "echo".to_string(),
994            "hello world".to_string(),
995            "it's here".to_string(),
996            "a \"quoted\" thing".to_string(),
997        ]);
998        assert_eq!(code, 0);
999    }
1000
1001    #[test]
1002    fn exec_direct_nonexistent_returns_127() {
1003        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
1004        assert_eq!(code, 127);
1005    }
1006
1007    #[test]
1008    fn exec_argv_empty_returns_127() {
1009        let code = super::exec_argv(&[]);
1010        assert_eq!(code, 127);
1011    }
1012
1013    #[test]
1014    fn exec_argv_runs_simple_command() {
1015        let _lock = crate::core::data_dir::test_env_lock();
1016        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1017        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1018        let code = super::exec_argv(&["true".to_string()]);
1019        assert_eq!(code, 0);
1020    }
1021
1022    #[test]
1023    fn exec_argv_passes_through_when_disabled() {
1024        let _lock = crate::core::data_dir::test_env_lock();
1025        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1026        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
1027        let code = super::exec_argv(&["true".to_string()]);
1028        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1029        assert_eq!(code, 0);
1030    }
1031
1032    // Finding 1 (GH security audit): the `-t` track path is the default shell
1033    // hook, so it must enforce the allowlist exactly like the `-c` path. A
1034    // non-allowlisted command must be blocked (126), not executed.
1035    #[test]
1036    fn exec_argv_enforces_allowlist_for_disallowed_command() {
1037        let _lock = crate::core::data_dir::test_env_lock();
1038        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1039        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1040        crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
1041        // hook-child forces enforcement regardless of the test runner's TTY state.
1042        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1043        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
1044
1045        let code = super::exec_argv(&["true".to_string()]);
1046
1047        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1048        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1049
1050        assert_eq!(
1051            code, 126,
1052            "non-allowlisted command must be blocked on the -t track path"
1053        );
1054    }
1055
1056    #[test]
1057    fn exec_argv_allows_allowlisted_command() {
1058        let _lock = crate::core::data_dir::test_env_lock();
1059        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1060        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1061        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1062        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1063
1064        let code = super::exec_argv(&["true".to_string()]);
1065
1066        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1067        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1068
1069        assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1070    }
1071
1072    #[test]
1073    fn wait_with_limits_captures_output() {
1074        let child = std::process::Command::new("echo")
1075            .arg("hello")
1076            .stdout(std::process::Stdio::piped())
1077            .stderr(std::process::Stdio::piped())
1078            .spawn()
1079            .unwrap();
1080
1081        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1082        let stdout = String::from_utf8_lossy(&output.stdout);
1083        assert!(
1084            stdout.contains("hello"),
1085            "expected 'hello' in output: {stdout}"
1086        );
1087        assert!(output.status.success());
1088    }
1089
1090    #[test]
1091    fn wait_with_limits_truncates_large_output() {
1092        // Generate ~100 KB of output, limit to 1 KB
1093        let child = std::process::Command::new("sh")
1094            .args(["-c", "yes 'aaaa' | head -25000"])
1095            .stdout(std::process::Stdio::piped())
1096            .stderr(std::process::Stdio::piped())
1097            .spawn()
1098            .unwrap();
1099
1100        let output =
1101            super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1102        let stdout = String::from_utf8_lossy(&output.stdout);
1103        assert!(
1104            stdout.contains("[lean-ctx: output truncated"),
1105            "expected truncation notice, got len={}: ...{}",
1106            stdout.len(),
1107            &stdout[stdout.len().saturating_sub(80)..]
1108        );
1109    }
1110
1111    #[test]
1112    fn wait_with_limits_timeout_kills_process() {
1113        let child = std::process::Command::new("sleep")
1114            .arg("60")
1115            .stdout(std::process::Stdio::piped())
1116            .stderr(std::process::Stdio::piped())
1117            .spawn()
1118            .unwrap();
1119
1120        let start = std::time::Instant::now();
1121        let output =
1122            super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1123        let elapsed = start.elapsed();
1124
1125        assert!(
1126            elapsed < std::time::Duration::from_secs(3),
1127            "timeout should kill quickly, took {elapsed:?}"
1128        );
1129        let stdout = String::from_utf8_lossy(&output.stdout);
1130        assert!(stdout.contains("[lean-ctx: output truncated"));
1131    }
1132
1133    /// GH #720: killing only the direct child (a shell) on timeout leaves its
1134    /// grandchildren alive holding the stdout pipe — the reader threads never
1135    /// see EOF and `wait_with_limits` blocks forever even though the timeout
1136    /// fired. With the child in its own process group and a group kill, the
1137    /// whole tree dies and the call returns promptly.
1138    #[cfg(unix)]
1139    #[test]
1140    fn wait_with_limits_group_kill_reaps_grandchildren() {
1141        use std::os::unix::process::CommandExt as _;
1142        // The shell spawns a grandchild that inherits stdout and sleeps far
1143        // beyond the timeout; the shell itself also sleeps so the timeout path
1144        // (not natural exit) is exercised.
1145        let mut cmd = std::process::Command::new("sh");
1146        cmd.args(["-c", "sleep 30 & sleep 30"])
1147            .stdin(std::process::Stdio::null())
1148            .stdout(std::process::Stdio::piped())
1149            .stderr(std::process::Stdio::piped());
1150        cmd.process_group(0);
1151        let child = cmd.spawn().unwrap();
1152        let pgid = child.id() as libc::pid_t;
1153
1154        let start = std::time::Instant::now();
1155        let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1156        let elapsed = start.elapsed();
1157
1158        assert!(
1159            elapsed < std::time::Duration::from_secs(5),
1160            "group kill must unblock the reader threads, took {elapsed:?}"
1161        );
1162        // The whole group must be gone (ESRCH), not just the direct child.
1163        // A brief grace period lets the kernel finish reaping.
1164        let mut group_gone = false;
1165        for _ in 0..50 {
1166            // SAFETY: signal 0 only probes for existence.
1167            if unsafe { libc::killpg(pgid, 0) } == -1 {
1168                group_gone = true;
1169                break;
1170            }
1171            std::thread::sleep(std::time::Duration::from_millis(20));
1172        }
1173        assert!(group_gone, "process group {pgid} must be fully reaped");
1174    }
1175
1176    /// #806: piped stdin must be forwarded to the child via Stdio::piped()
1177    /// and relayed, not nulled. Tests the relay pattern: write data to child
1178    /// stdin, close it (EOF), child reads and exits.
1179    #[cfg(unix)]
1180    #[test]
1181    fn stdin_relay_forwards_piped_data() {
1182        use std::io::Write;
1183        use std::os::unix::process::CommandExt as _;
1184
1185        let mut cmd = std::process::Command::new("cat");
1186        cmd.stdin(std::process::Stdio::piped())
1187            .stdout(std::process::Stdio::piped())
1188            .stderr(std::process::Stdio::piped());
1189        cmd.process_group(0);
1190        let mut child = cmd.spawn().expect("failed to spawn cat");
1191
1192        let mut child_stdin = child.stdin.take().unwrap();
1193        std::thread::spawn(move || {
1194            child_stdin.write_all(b"hello from pipe\n").unwrap();
1195            drop(child_stdin);
1196        });
1197
1198        let output = child.wait_with_output().expect("wait failed");
1199        let stdout = String::from_utf8_lossy(&output.stdout);
1200        assert!(
1201            stdout.contains("hello from pipe"),
1202            "#806: piped stdin must reach the child, got: {stdout}"
1203        );
1204    }
1205
1206    /// #806: commands that don't read stdin must still work normally
1207    /// when no data is piped (relay thread sees immediate EOF from parent).
1208    #[cfg(unix)]
1209    #[test]
1210    fn stdin_relay_no_data_does_not_hang() {
1211        let start = std::time::Instant::now();
1212        let mut cmd = std::process::Command::new("sh");
1213        cmd.args(["-c", "echo ok"])
1214            .stdin(std::process::Stdio::piped())
1215            .stdout(std::process::Stdio::piped())
1216            .stderr(std::process::Stdio::piped());
1217        use std::os::unix::process::CommandExt as _;
1218        cmd.process_group(0);
1219        let mut child = cmd.spawn().unwrap();
1220        // Close stdin immediately (simulates relay with empty parent pipe)
1221        drop(child.stdin.take());
1222        let output = child.wait_with_output().unwrap();
1223        let elapsed = start.elapsed();
1224        assert!(
1225            elapsed < std::time::Duration::from_secs(5),
1226            "must not hang when stdin is closed immediately, took {elapsed:?}"
1227        );
1228        let stdout = String::from_utf8_lossy(&output.stdout);
1229        assert!(stdout.contains("ok"), "command output missing: {stdout}");
1230    }
1231
1232    #[test]
1233    fn heavy_commands_get_higher_byte_limits() {
1234        // exec_limits owns the byte ceiling; timeout resolution is covered by
1235        // `shell_timeout_resolves_heavy_normal_and_env_overrides` (which is
1236        // env/config-isolated, so these stay deterministic regardless of the
1237        // operator's config.toml).
1238        for cmd in [
1239            "cargo build --release",
1240            "cargo test --lib",
1241            "cargo nextest run",
1242            "npm run build",
1243            "docker build -t myapp .",
1244            // Git verbs that fire build/test hooks (pre-commit clippy, pre-push
1245            // preflight) must not be killed at the default ceiling (#854).
1246            "git commit --amend --no-edit",
1247            "git push -u origin HEAD",
1248            // Agents prefix with `cd /path && ...` — heavy detection must
1249            // look through it to avoid 120s timeout on builds.
1250            "cd /some/path && cargo test --lib",
1251            "cd /foo/bar && cargo build --release",
1252            "cd /workspace; npm ci",
1253        ] {
1254            let (bytes, _) = super::exec_limits(cmd);
1255            assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1256        }
1257    }
1258
1259    #[test]
1260    fn normal_commands_get_default_byte_limits() {
1261        // Read-only git verbs stay on the default ceiling — only `commit`/`push`
1262        // (which fire the cargo-heavy hooks) are promoted.
1263        for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1264            let (bytes, _) = super::exec_limits(cmd);
1265            assert_eq!(
1266                bytes,
1267                super::DEFAULT_MAX_BYTES,
1268                "default byte limit for {cmd}"
1269            );
1270        }
1271    }
1272
1273    #[test]
1274    fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1275        // Serialize env mutation so this never races other env-reading tests.
1276        let _lock = crate::core::data_dir::test_env_lock();
1277        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1278        let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1279        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1280        for v in [
1281            "LEAN_CTX_SHELL_TIMEOUT_MS",
1282            "LEAN_CTX_SHELL_TIMEOUT_SECS",
1283            "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1284        ] {
1285            crate::test_env::remove_var(v);
1286        }
1287
1288        // Heavy builds/tests and hook-firing git verbs get the heavy ceiling;
1289        // read-only verbs stay on the default. Preserves the #854 promotion.
1290        assert_eq!(
1291            super::shell_timeout("cargo install --path ."),
1292            super::HEAVY_TIMEOUT
1293        );
1294        assert_eq!(
1295            super::shell_timeout("cargo nextest run"),
1296            super::HEAVY_TIMEOUT
1297        );
1298        assert_eq!(
1299            super::shell_timeout("git commit -m 'wip'"),
1300            super::HEAVY_TIMEOUT
1301        );
1302        assert_eq!(
1303            super::shell_timeout("git push origin main"),
1304            super::HEAVY_TIMEOUT
1305        );
1306        assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1307        assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1308        // `cd ... && heavy` must resolve to HEAVY so agents don't get killed at 120s.
1309        assert_eq!(
1310            super::shell_timeout("cd /some/project && cargo test --lib"),
1311            super::HEAVY_TIMEOUT
1312        );
1313        assert_eq!(
1314            super::shell_timeout("cd /workspace && cargo build --release"),
1315            super::HEAVY_TIMEOUT
1316        );
1317        assert_eq!(
1318            super::shell_timeout("cd /app; npm ci"),
1319            super::HEAVY_TIMEOUT
1320        );
1321
1322        // Per-tier env overrides win over the built-in constants. (Non-round
1323        // second values keep the literals clippy-clean and unambiguous.)
1324        crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1325        assert_eq!(
1326            super::shell_timeout("cargo build"),
1327            std::time::Duration::from_secs(90)
1328        );
1329        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1330
1331        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1332        assert_eq!(
1333            super::shell_timeout("git status"),
1334            std::time::Duration::from_secs(30)
1335        );
1336        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1337
1338        // The universal millisecond override wins over everything.
1339        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1340        assert_eq!(
1341            super::shell_timeout("cargo build"),
1342            std::time::Duration::from_secs(5)
1343        );
1344        assert_eq!(
1345            super::shell_timeout("git status"),
1346            std::time::Duration::from_secs(5)
1347        );
1348        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1349
1350        for (var, saved) in [
1351            ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1352            ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1353            ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1354        ] {
1355            if let Some(v) = saved {
1356                crate::test_env::set_var(var, v);
1357            }
1358        }
1359    }
1360
1361    // Task runners (mise/just) wrap builds and test gates that routinely run
1362    // past the 2-minute default; killing them mid-run loses the whole job.
1363    // They get the heavy ceiling like the underlying build tools they invoke.
1364    #[test]
1365    fn task_runners_get_heavy_ceiling() {
1366        let _lock = crate::core::data_dir::test_env_lock();
1367        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1368        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1369        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1370        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1371
1372        assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1373        assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1374        assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1375
1376        if let Some(v) = saved_ms {
1377            crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1378        }
1379        if let Some(v) = saved_heavy {
1380            crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1381        }
1382    }
1383
1384    // Per-call `timeout_ms` (ctx_shell tool arg): explicit caller intent beats
1385    // the built-in tiers in both directions, absurd values clamp to the 1h
1386    // ceiling, zero is ignored, and the operator's universal env pin stays top.
1387    #[test]
1388    fn per_call_timeout_override_resolves_and_clamps() {
1389        let _lock = crate::core::data_dir::test_env_lock();
1390        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1391        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1392
1393        assert_eq!(
1394            super::shell_timeout_with_override("git status", Some(300_000)),
1395            std::time::Duration::from_mins(5)
1396        );
1397        assert_eq!(
1398            super::shell_timeout_with_override("cargo build", Some(30_000)),
1399            std::time::Duration::from_secs(30)
1400        );
1401        assert_eq!(
1402            super::shell_timeout_with_override("git status", Some(999_000_000)),
1403            std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1404        );
1405        assert_eq!(
1406            super::shell_timeout_with_override("git status", Some(0)),
1407            super::DEFAULT_TIMEOUT
1408        );
1409        assert_eq!(
1410            super::shell_timeout_with_override("git status", None),
1411            super::DEFAULT_TIMEOUT
1412        );
1413
1414        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1415        assert_eq!(
1416            super::shell_timeout_with_override("git status", Some(300_000)),
1417            std::time::Duration::from_secs(5)
1418        );
1419        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1420        if let Some(v) = saved_ms {
1421            crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1422        }
1423    }
1424
1425    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
1426    #[test]
1427    fn allowlist_enforces_in_hook_child_mode() {
1428        // Hook-child wins over everything, even an interactive TTY.
1429        assert!(super::allowlist_must_enforce_inner(true, false, true));
1430        assert!(super::allowlist_must_enforce_inner(true, true, true));
1431    }
1432
1433    #[test]
1434    fn allowlist_enforces_for_non_interactive_callers() {
1435        // Agent/script invocation: stderr is a pipe → enforce.
1436        assert!(super::allowlist_must_enforce_inner(false, false, false));
1437    }
1438
1439    #[test]
1440    fn allowlist_warns_for_interactive_humans() {
1441        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
1442        assert!(!super::allowlist_must_enforce_inner(false, false, true));
1443    }
1444
1445    #[test]
1446    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1447        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
1448        assert!(!super::allowlist_must_enforce_inner(false, true, false));
1449        assert!(super::allowlist_must_enforce_inner(true, true, false));
1450    }
1451}