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    HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
329}
330
331/// Execute a command from pre-split argv without going through `sh -c`.
332/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
333/// already correctly split by the user's shell, so re-serializing them
334/// into a string and re-parsing via `sh -c` would risk mangling complex
335/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
336pub fn exec_argv(args: &[String]) -> i32 {
337    if args.is_empty() {
338        return 127;
339    }
340
341    // Quote-safe join used only for the allowlist/policy *checks*; execution
342    // below still consumes the pre-split argv verbatim (the whole reason `-t`
343    // avoids `sh -c`). Joining first means a single argv element such as
344    // `git status; rm -rf /` is checked as ONE quoted token, never re-parsed.
345    let joined = super::platform::join_command(args);
346
347    // #595: unwrap a host command wrapper (eval + cwd snapshot) before any
348    // checks so the real command — not the wrapper — is gated and run. The `-t`
349    // path cannot exec a compound argv, so route the rebuild through `exec`.
350    if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
351        return exec(&u.rebuild());
352    }
353
354    // The `-t` track path is the agent's default shell hook
355    // (`_lc() { lean-ctx -t "$@" }`), so it MUST enforce the same allowlist
356    // boundary as `-c` (see `exec`). Previously it skipped the check entirely,
357    // letting every aliased multi-arg invocation (`_lc git …`) bypass the
358    // restriction that `lean-ctx -c` enforces (GH security audit, finding 1).
359    if let Some(code) = allowlist_gate(&joined) {
360        return code;
361    }
362
363    if super::reentry::should_pass_through() {
364        return exec_direct(args);
365    }
366
367    let cfg = config::Config::load();
368    let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
369
370    if policy.is_protected() {
371        let code = exec_direct(args);
372        crate::core::tool_lifecycle::record_shell_command(0, 0);
373        return code;
374    }
375
376    let code = exec_direct(args);
377    crate::core::tool_lifecycle::record_shell_command(0, 0);
378    code
379}
380
381fn exec_direct(args: &[String]) -> i32 {
382    let mut cmd = Command::new(&args[0]);
383    cmd.args(&args[1..])
384        .stdin(Stdio::inherit())
385        .stdout(Stdio::inherit())
386        .stderr(Stdio::inherit());
387    super::reentry::mark_child(&mut cmd);
388    super::platform::apply_utf8_locale(&mut cmd);
389    let status = cmd.status();
390
391    match status {
392        Ok(s) => s.code().unwrap_or(1),
393        Err(e) => {
394            tracing::error!("lean-ctx: failed to execute: {e}");
395            127
396        }
397    }
398}
399
400/// Decides whether an allowlist violation on the CLI path blocks (exit 126) or
401/// only warns.
402///
403/// Enforced when:
404/// - hook-child mode (`LEAN_CTX_HOOK_CHILD`): lean-ctx is the agent's
405///   command-interception channel and must not be weaker than the MCP path, or
406/// - stderr is not a TTY: a non-interactive caller is an agent or script, and
407///   agent-driven `lean-ctx -c` must enforce the same boundary as ctx_shell.
408///
409/// Warn-only when a human runs `lean-ctx -c` at an interactive terminal (they
410/// can run the command without lean-ctx anyway, so blocking adds friction, not
411/// a boundary) or when `LEAN_CTX_ALLOWLIST_WARN_ONLY=1` explicitly opts out.
412fn allowlist_must_enforce() -> bool {
413    let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
414    let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
415        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
416    allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
417}
418
419/// Pure decision core of [`allowlist_must_enforce`] (unit-testable without
420/// process-global env/TTY state).
421fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
422    if hook_child {
423        return true;
424    }
425    if warn_only {
426        return false;
427    }
428    !stderr_is_tty
429}
430
431/// True when this process's stdout is a **regular file** — i.e. the caller
432/// redirected output to a file (`cmd > out`, `cmd >> out`).
433///
434/// Output captured to a file is consumed as *data*, so it must stay byte-faithful:
435/// compression would silently drop/abbreviate lines and corrupt the file
436/// (e.g. `git status --short > files.txt` losing entries). Pipes (agent capture)
437/// and TTYs are NOT regular files and return `false`, so they keep their normal
438/// behavior — this only ever *adds* a verbatim guarantee, never removes one.
439///
440/// Uses only `std`: it wraps the existing stdout descriptor in a `ManuallyDrop`
441/// `File` purely to read its metadata (`fstat` on Unix, `GetFileInformation` on
442/// Windows) without ever closing the real stdout.
443fn stdout_is_regular_file() -> bool {
444    #[cfg(unix)]
445    {
446        use std::os::unix::io::{AsRawFd, FromRawFd};
447        let fd = io::stdout().as_raw_fd();
448        // SAFETY: fd 1 stays valid for the whole process. `ManuallyDrop` prevents
449        // the wrapper's `Drop` from closing stdout; we only read metadata.
450        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
451        file.metadata().is_ok_and(|m| m.is_file())
452    }
453    #[cfg(windows)]
454    {
455        use std::os::windows::io::{AsRawHandle, FromRawHandle};
456        let handle = io::stdout().as_raw_handle();
457        // SAFETY: the stdout handle stays valid for the whole process.
458        // `ManuallyDrop` prevents the wrapper's `Drop` from closing it.
459        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
460        file.metadata().is_ok_and(|m| m.is_file())
461    }
462    #[cfg(not(any(unix, windows)))]
463    {
464        false
465    }
466}
467
468/// Shared allowlist gate for the CLI shell entrypoints — `-c` (via [`exec`]) and
469/// `-t` (via [`exec_argv`]). Both must apply the SAME boundary so the track path
470/// (the default shell hook) cannot be weaker than the compress path.
471///
472/// Returns `Some(126)` when the command is blocked and the caller must return
473/// that exit code; `None` when execution may proceed (allowed, or warn-only for
474/// an interactive human — see [`allowlist_must_enforce`]).
475fn allowlist_gate(command: &str) -> Option<i32> {
476    if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
477        if allowlist_must_enforce() {
478            eprintln!("{msg}");
479            eprintln!(
480                "lean-ctx: command blocked by shell allowlist. \
481                 Allow it permanently: lean-ctx allow <cmd> — or set \
482                 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
483            );
484            return Some(126);
485        }
486        // Diagnostic, not user feedback: an interactive human at a TTY can run
487        // the command without lean-ctx anyway, and surfacing a WARN in their
488        // plain terminal is exactly the confusion GH #699 reported. Keep the
489        // warning for non-TTY callers (agents that opted into warn-only).
490        if io::stderr().is_terminal() {
491            tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
492        } else {
493            tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
494        }
495    }
496    None
497}
498
499pub fn exec(command: &str) -> i32 {
500    // #595: when the agent wraps its command in host scaffolding
501    // (`… && eval '<cmd>' … && pwd -P >| …-cwd`), look through it so the allowlist
502    // and compression act on the REAL command, not the wrapper — whose `eval` the
503    // allowlist would otherwise hard-block on every single call. The cwd snapshot
504    // is preserved so the host keeps tracking the working directory.
505    let unwrapped = super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
506    let mut collapsed_nested = false;
507    let collapsed;
508    let command = unwrapped.as_deref().unwrap_or(command);
509    let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
510        collapsed_nested = true;
511        collapsed = c;
512        collapsed.as_str()
513    } else {
514        command
515    };
516
517    if let Some(code) = allowlist_gate(command) {
518        return code;
519    }
520
521    let (shell, shell_flag) = super::platform::shell_and_flag();
522    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
523    let command = command.as_str();
524
525    if super::reentry::is_disabled() {
526        return exec_inherit(command, &shell, &shell_flag);
527    }
528    if should_delegate_wrapped_to_shell_default(collapsed_nested) {
529        return exec_shell_default(command, &shell, &shell_flag);
530    }
531
532    let cfg = config::Config::load();
533    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
534    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
535
536    if raw_mode {
537        return exec_inherit_tracked(command, &shell, &shell_flag);
538    }
539
540    let policy = super::output_policy::classify(command, &cfg.excluded_commands);
541
542    // Passthrough: ALWAYS bypass compression, even with force_compress.
543    if policy == super::output_policy::OutputPolicy::Passthrough {
544        return exec_inherit_tracked(command, &shell, &shell_flag);
545    }
546
547    // Verbatim: bypass compression unless force_compress is set,
548    // in which case use buffered path (compress_if_beneficial will
549    // respect the verbatim classification and only size-cap).
550    if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
551        return exec_inherit_tracked(command, &shell, &shell_flag);
552    }
553
554    if !force_compress {
555        if io::stdout().is_terminal() {
556            return exec_inherit_tracked(command, &shell, &shell_flag);
557        }
558        let code = exec_inherit(command, &shell, &shell_flag);
559        crate::core::tool_lifecycle::record_shell_command(0, 0);
560        return code;
561    }
562
563    // Compression is forced (`-c` / LEAN_CTX_COMPRESS, e.g. the agent shell hook).
564    // It must STILL never alter bytes destined for a file: a redirect
565    // (`cmd > out`, `cmd >> out`) means the output is captured as data, not read by
566    // a human or agent. Writing the compressed digest there would silently
567    // drop/abbreviate lines and corrupt the file (e.g. contradictory `git diff`
568    // dumps). Pass redirected-to-file output through verbatim; pipes (agent
569    // capture) and TTYs keep compressing. This is the single choke point, so it
570    // holds for every caller (hook, direct CLI, Pi/MCP bridges).
571    if stdout_is_regular_file() {
572        return exec_inherit_tracked(command, &shell, &shell_flag);
573    }
574
575    exec_buffered(command, &shell, &shell_flag, &cfg)
576}
577
578fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
579    let mut current = command.trim().to_string();
580    let mut changed = false;
581
582    while let Some(next) = strip_one_lean_ctx_exec(&current) {
583        if next == current {
584            break;
585        }
586        current = next;
587        changed = true;
588    }
589
590    changed.then_some(current)
591}
592
593fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
594    // After collapsing `lean-ctx -c "lean-ctx -c ..."` the current process is the
595    // one compression pass that would otherwise be owned by the shell default.
596    // Delegating again would drop back to raw execution or re-enter the hook.
597    super::reentry::is_wrapped() && !collapsed_nested
598}
599
600fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
601    let words = split_simple_shell_words(command)?;
602    if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
603        return None;
604    }
605    if words[1].value != "-c" && words[1].value != "exec" {
606        return None;
607    }
608    if words[2..].iter().any(|w| {
609        matches!(
610            w.value.as_str(),
611            "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
612        )
613    }) {
614        return None;
615    }
616    if words.len() == 3 {
617        Some(words[2].value.trim().to_string())
618    } else {
619        Some(command[words[2].start..].trim().to_string())
620    }
621}
622
623fn is_lean_ctx_bin(word: &str) -> bool {
624    std::path::Path::new(word)
625        .file_name()
626        .and_then(|name| name.to_str())
627        .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
628}
629
630struct SimpleShellWord {
631    value: String,
632    start: usize,
633}
634
635fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
636    let mut words = Vec::new();
637    let mut current = String::new();
638    let mut current_start: Option<usize> = None;
639    let mut chars = command.char_indices().peekable();
640    let mut quote: Option<char> = None;
641
642    while let Some((idx, ch)) = chars.next() {
643        match quote {
644            Some('\'') if ch == '\'' => quote = None,
645            Some('"') if ch == '"' => quote = None,
646            None if ch == '\'' || ch == '"' => {
647                current_start.get_or_insert(idx);
648                quote = Some(ch);
649            }
650            Some('"') | None if ch == '\\' => {
651                current_start.get_or_insert(idx);
652                if let Some((_, next)) = chars.next() {
653                    current.push(next);
654                }
655            }
656            None if ch.is_whitespace() => {
657                if let Some(start) = current_start.take() {
658                    words.push(SimpleShellWord {
659                        value: std::mem::take(&mut current),
660                        start,
661                    });
662                }
663            }
664            Some(_) | None => {
665                current_start.get_or_insert(idx);
666                current.push(ch);
667            }
668        }
669    }
670
671    if quote.is_some() {
672        return None;
673    }
674    if let Some(start) = current_start {
675        words.push(SimpleShellWord {
676            value: current,
677            start,
678        });
679    }
680    (!words.is_empty()).then_some(words)
681}
682
683fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
684    let mut cmd = Command::new(shell);
685    cmd.arg(shell_flag)
686        .arg(command)
687        .stdin(Stdio::inherit())
688        .stdout(Stdio::inherit())
689        .stderr(Stdio::inherit());
690    super::reentry::mark_child(&mut cmd);
691    super::platform::apply_utf8_locale(&mut cmd);
692    super::platform::apply_profile_free_env(&mut cmd);
693    let status = cmd.status();
694
695    match status {
696        Ok(s) => s.code().unwrap_or(1),
697        Err(e) => {
698            tracing::error!("lean-ctx: failed to execute: {e}");
699            127
700        }
701    }
702}
703
704fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
705    let mut cmd = Command::new(shell);
706    cmd.arg(shell_flag)
707        .arg(command)
708        .stdin(Stdio::inherit())
709        .stdout(Stdio::inherit())
710        .stderr(Stdio::inherit());
711    super::reentry::clear_shell_default_markers(&mut cmd);
712    super::platform::apply_utf8_locale(&mut cmd);
713    super::platform::apply_profile_free_env(&mut cmd);
714    let status = cmd.status();
715
716    match status {
717        Ok(s) => s.code().unwrap_or(1),
718        Err(e) => {
719            eprintln!("lean-ctx: failed to execute '{command}': {e}");
720            127
721        }
722    }
723}
724
725fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
726    let code = exec_inherit(command, shell, shell_flag);
727    crate::core::tool_lifecycle::record_shell_command(0, 0);
728    code
729}
730
731/// Label inserted between stdout and stderr of a FAILED command so the agent can
732/// attribute the error to the right stream instead of guessing — and never has to
733/// re-run the command raw just to locate the failure. See #809 / #812.
734pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
735
736/// Join captured stdout and stderr for display/recovery. On failure (non-zero
737/// exit) with both streams present, a labeled delimiter separates them; success
738/// output keeps the plain `stdout\nstderr` shape (determinism, #498).
739pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
740    match (stdout.is_empty(), stderr.is_empty()) {
741        (_, true) => stdout.to_string(),
742        (true, false) => stderr.to_string(),
743        (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
744        (false, false) => format!("{stdout}\n{stderr}"),
745    }
746}
747
748fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
749    #[cfg(windows)]
750    super::platform::set_console_utf8();
751
752    let start = std::time::Instant::now();
753
754    let mut cmd = Command::new(shell);
755
756    #[cfg(windows)]
757    let ps_tmp_path: Option<tempfile::TempPath>;
758    #[cfg(windows)]
759    {
760        if super::platform::is_powershell(shell) {
761            let ps_script = format!(
762                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
763                command
764            );
765            // A temp script lets us set UTF-8 output encoding. If the temp file
766            // cannot be created (full disk, perms, broken TMP), degrade to
767            // running the command inline rather than panicking the process.
768            match tempfile::Builder::new()
769                .prefix("lean-ctx-ps-")
770                .suffix(".ps1")
771                .tempfile()
772            {
773                Ok(tmp) => {
774                    let tmp_path = tmp.into_temp_path();
775                    let _ = std::fs::write(&tmp_path, &ps_script);
776                    cmd.args([
777                        "-NoProfile",
778                        "-ExecutionPolicy",
779                        "Bypass",
780                        "-File",
781                        &tmp_path.to_string_lossy(),
782                    ]);
783                    ps_tmp_path = Some(tmp_path);
784                }
785                Err(e) => {
786                    tracing::warn!(
787                        "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
788                    );
789                    cmd.arg(shell_flag);
790                    cmd.arg(command);
791                    ps_tmp_path = None;
792                }
793            }
794        } else {
795            cmd.arg(shell_flag);
796            cmd.arg(command);
797            ps_tmp_path = None;
798        }
799    }
800    #[cfg(not(windows))]
801    {
802        cmd.arg(shell_flag);
803        cmd.arg(command);
804    }
805
806    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
807    // #720: the buffered path serves agents and pipes — there is no
808    // interactive stdin to forward. Inheriting the host's stdin let
809    // stdin-reading commands (`rg` with no path after an empty `$(…)`
810    // substitution, `cat` without a file) block forever on a pipe that never
811    // delivers EOF, wedging the host's persistent shell session. /dev/null
812    // answers with EOF immediately. A real TTY stdin is preserved so
813    // interactive `lean-ctx -c` (prompts, sudo) keeps working — and only in
814    // the non-TTY case do we detach the child into its own process group,
815    // so the timeout kill can reap grandchildren without stealing Ctrl+C
816    // from interactive users.
817    let isolate = !io::stdin().is_terminal();
818    if isolate {
819        cmd.stdin(Stdio::null());
820        #[cfg(unix)]
821        {
822            use std::os::unix::process::CommandExt as _;
823            cmd.process_group(0);
824        }
825    }
826    super::reentry::mark_child(&mut cmd);
827    super::platform::apply_utf8_locale(&mut cmd);
828    super::platform::apply_profile_free_env(&mut cmd);
829    let child = cmd.spawn();
830
831    let child = match child {
832        Ok(c) => c,
833        Err(e) => {
834            tracing::error!("lean-ctx: failed to execute: {e}");
835            #[cfg(windows)]
836            if let Some(ref tmp) = ps_tmp_path {
837                let _ = std::fs::remove_file(tmp);
838            }
839            return 127;
840        }
841    };
842
843    let (max_bytes, timeout) = exec_limits(command);
844    let output = wait_with_limits(child, max_bytes, timeout, isolate);
845
846    let duration_ms = start.elapsed().as_millis();
847    let exit_code = output.status.code().unwrap_or(1);
848    let stdout = super::platform::decode_output(&output.stdout);
849    let stderr = super::platform::decode_output(&output.stderr);
850
851    let full_output = combine_streams(&stdout, &stderr, exit_code);
852    let input_tokens = count_tokens(&full_output);
853
854    // Structured diagnostics (#499): failing cargo/tsc/eslint runs mark their
855    // files as context-priority; succeeding runs clear them.
856    crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
857
858    // Gotcha learning: a failing build/test pushes a pending error; the next
859    // green run of the same command base correlates the fix into a gotcha.
860    crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
861
862    let (compressed, output_tokens) =
863        super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
864
865    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
866
867    if !compressed.is_empty() {
868        let _ = io::stdout().write_all(compressed.as_bytes());
869        if !compressed.ends_with('\n') {
870            let _ = io::stdout().write_all(b"\n");
871        }
872    }
873    // Shared tee policy (#811): identical decision on the CLI and MCP paths —
874    // `Failures` keys off the real exit code, not a substring in the output.
875    let should_tee = super::tee_policy::should_tee(
876        &cfg.tee_mode,
877        exit_code,
878        full_output.trim().is_empty(),
879        input_tokens,
880        output_tokens,
881    );
882    if should_tee
883        && let Some(path) = super::redact::save_tee(command, &full_output)
884        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
885    {
886        eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
887    }
888
889    let threshold = cfg.slow_command_threshold_ms;
890    if threshold > 0 && duration_ms >= threshold as u128 {
891        slow_log::record(command, duration_ms, exit_code);
892    }
893
894    #[cfg(windows)]
895    if let Some(ref tmp) = ps_tmp_path {
896        let _ = std::fs::remove_file(tmp);
897    }
898
899    exit_code
900}
901
902#[cfg(test)]
903mod exec_tests {
904    #[test]
905    fn combine_streams_labels_stderr_on_failure() {
906        let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
907        assert_eq!(
908            out,
909            format!(
910                "build ok\n{}\nlinker: undefined symbol",
911                super::STDERR_LABEL
912            )
913        );
914    }
915
916    #[test]
917    fn combine_streams_plain_join_on_success() {
918        let out = super::combine_streams("step 1", "warning: noop", 0);
919        assert_eq!(out, "step 1\nwarning: noop");
920        assert!(!out.contains(super::STDERR_LABEL));
921    }
922
923    #[test]
924    fn combine_streams_single_stream_is_unchanged() {
925        assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
926        assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
927    }
928
929    #[test]
930    fn exec_direct_runs_true() {
931        let code = super::exec_direct(&["true".to_string()]);
932        assert_eq!(code, 0);
933    }
934
935    #[test]
936    fn exec_direct_runs_false() {
937        let code = super::exec_direct(&["false".to_string()]);
938        assert_ne!(code, 0);
939    }
940
941    #[test]
942    fn exec_direct_preserves_args_with_special_chars() {
943        let code = super::exec_direct(&[
944            "echo".to_string(),
945            "hello world".to_string(),
946            "it's here".to_string(),
947            "a \"quoted\" thing".to_string(),
948        ]);
949        assert_eq!(code, 0);
950    }
951
952    #[test]
953    fn exec_direct_nonexistent_returns_127() {
954        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
955        assert_eq!(code, 127);
956    }
957
958    #[test]
959    fn exec_argv_empty_returns_127() {
960        let code = super::exec_argv(&[]);
961        assert_eq!(code, 127);
962    }
963
964    #[test]
965    fn exec_argv_runs_simple_command() {
966        let _lock = crate::core::data_dir::test_env_lock();
967        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
968        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
969        let code = super::exec_argv(&["true".to_string()]);
970        assert_eq!(code, 0);
971    }
972
973    #[test]
974    fn exec_argv_passes_through_when_disabled() {
975        let _lock = crate::core::data_dir::test_env_lock();
976        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
977        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
978        let code = super::exec_argv(&["true".to_string()]);
979        crate::test_env::remove_var("LEAN_CTX_DISABLED");
980        assert_eq!(code, 0);
981    }
982
983    // Finding 1 (GH security audit): the `-t` track path is the default shell
984    // hook, so it must enforce the allowlist exactly like the `-c` path. A
985    // non-allowlisted command must be blocked (126), not executed.
986    #[test]
987    fn exec_argv_enforces_allowlist_for_disallowed_command() {
988        let _lock = crate::core::data_dir::test_env_lock();
989        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
990        crate::test_env::remove_var("LEAN_CTX_DISABLED");
991        crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
992        // hook-child forces enforcement regardless of the test runner's TTY state.
993        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
994        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
995
996        let code = super::exec_argv(&["true".to_string()]);
997
998        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
999        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1000
1001        assert_eq!(
1002            code, 126,
1003            "non-allowlisted command must be blocked on the -t track path"
1004        );
1005    }
1006
1007    #[test]
1008    fn exec_argv_allows_allowlisted_command() {
1009        let _lock = crate::core::data_dir::test_env_lock();
1010        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1011        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1012        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1013        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1014
1015        let code = super::exec_argv(&["true".to_string()]);
1016
1017        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1018        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1019
1020        assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1021    }
1022
1023    #[test]
1024    fn wait_with_limits_captures_output() {
1025        let child = std::process::Command::new("echo")
1026            .arg("hello")
1027            .stdout(std::process::Stdio::piped())
1028            .stderr(std::process::Stdio::piped())
1029            .spawn()
1030            .unwrap();
1031
1032        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1033        let stdout = String::from_utf8_lossy(&output.stdout);
1034        assert!(
1035            stdout.contains("hello"),
1036            "expected 'hello' in output: {stdout}"
1037        );
1038        assert!(output.status.success());
1039    }
1040
1041    #[test]
1042    fn wait_with_limits_truncates_large_output() {
1043        // Generate ~100 KB of output, limit to 1 KB
1044        let child = std::process::Command::new("sh")
1045            .args(["-c", "yes 'aaaa' | head -25000"])
1046            .stdout(std::process::Stdio::piped())
1047            .stderr(std::process::Stdio::piped())
1048            .spawn()
1049            .unwrap();
1050
1051        let output =
1052            super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1053        let stdout = String::from_utf8_lossy(&output.stdout);
1054        assert!(
1055            stdout.contains("[lean-ctx: output truncated"),
1056            "expected truncation notice, got len={}: ...{}",
1057            stdout.len(),
1058            &stdout[stdout.len().saturating_sub(80)..]
1059        );
1060    }
1061
1062    #[test]
1063    fn wait_with_limits_timeout_kills_process() {
1064        let child = std::process::Command::new("sleep")
1065            .arg("60")
1066            .stdout(std::process::Stdio::piped())
1067            .stderr(std::process::Stdio::piped())
1068            .spawn()
1069            .unwrap();
1070
1071        let start = std::time::Instant::now();
1072        let output =
1073            super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1074        let elapsed = start.elapsed();
1075
1076        assert!(
1077            elapsed < std::time::Duration::from_secs(3),
1078            "timeout should kill quickly, took {elapsed:?}"
1079        );
1080        let stdout = String::from_utf8_lossy(&output.stdout);
1081        assert!(stdout.contains("[lean-ctx: output truncated"));
1082    }
1083
1084    /// GH #720: killing only the direct child (a shell) on timeout leaves its
1085    /// grandchildren alive holding the stdout pipe — the reader threads never
1086    /// see EOF and `wait_with_limits` blocks forever even though the timeout
1087    /// fired. With the child in its own process group and a group kill, the
1088    /// whole tree dies and the call returns promptly.
1089    #[cfg(unix)]
1090    #[test]
1091    fn wait_with_limits_group_kill_reaps_grandchildren() {
1092        use std::os::unix::process::CommandExt as _;
1093        // The shell spawns a grandchild that inherits stdout and sleeps far
1094        // beyond the timeout; the shell itself also sleeps so the timeout path
1095        // (not natural exit) is exercised.
1096        let mut cmd = std::process::Command::new("sh");
1097        cmd.args(["-c", "sleep 30 & sleep 30"])
1098            .stdin(std::process::Stdio::null())
1099            .stdout(std::process::Stdio::piped())
1100            .stderr(std::process::Stdio::piped());
1101        cmd.process_group(0);
1102        let child = cmd.spawn().unwrap();
1103        let pgid = child.id() as libc::pid_t;
1104
1105        let start = std::time::Instant::now();
1106        let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1107        let elapsed = start.elapsed();
1108
1109        assert!(
1110            elapsed < std::time::Duration::from_secs(5),
1111            "group kill must unblock the reader threads, took {elapsed:?}"
1112        );
1113        // The whole group must be gone (ESRCH), not just the direct child.
1114        // A brief grace period lets the kernel finish reaping.
1115        let mut group_gone = false;
1116        for _ in 0..50 {
1117            // SAFETY: signal 0 only probes for existence.
1118            if unsafe { libc::killpg(pgid, 0) } == -1 {
1119                group_gone = true;
1120                break;
1121            }
1122            std::thread::sleep(std::time::Duration::from_millis(20));
1123        }
1124        assert!(group_gone, "process group {pgid} must be fully reaped");
1125    }
1126
1127    #[test]
1128    fn heavy_commands_get_higher_byte_limits() {
1129        // exec_limits owns the byte ceiling; timeout resolution is covered by
1130        // `shell_timeout_resolves_heavy_normal_and_env_overrides` (which is
1131        // env/config-isolated, so these stay deterministic regardless of the
1132        // operator's config.toml).
1133        for cmd in [
1134            "cargo build --release",
1135            "cargo test --lib",
1136            "cargo nextest run",
1137            "npm run build",
1138            "docker build -t myapp .",
1139            // Git verbs that fire build/test hooks (pre-commit clippy, pre-push
1140            // preflight) must not be killed at the default ceiling (#854).
1141            "git commit --amend --no-edit",
1142            "git push -u origin HEAD",
1143        ] {
1144            let (bytes, _) = super::exec_limits(cmd);
1145            assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1146        }
1147    }
1148
1149    #[test]
1150    fn normal_commands_get_default_byte_limits() {
1151        // Read-only git verbs stay on the default ceiling — only `commit`/`push`
1152        // (which fire the cargo-heavy hooks) are promoted.
1153        for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1154            let (bytes, _) = super::exec_limits(cmd);
1155            assert_eq!(
1156                bytes,
1157                super::DEFAULT_MAX_BYTES,
1158                "default byte limit for {cmd}"
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1165        // Serialize env mutation so this never races other env-reading tests.
1166        let _lock = crate::core::data_dir::test_env_lock();
1167        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1168        let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1169        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1170        for v in [
1171            "LEAN_CTX_SHELL_TIMEOUT_MS",
1172            "LEAN_CTX_SHELL_TIMEOUT_SECS",
1173            "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1174        ] {
1175            crate::test_env::remove_var(v);
1176        }
1177
1178        // Heavy builds/tests and hook-firing git verbs get the heavy ceiling;
1179        // read-only verbs stay on the default. Preserves the #854 promotion.
1180        assert_eq!(
1181            super::shell_timeout("cargo install --path ."),
1182            super::HEAVY_TIMEOUT
1183        );
1184        assert_eq!(
1185            super::shell_timeout("cargo nextest run"),
1186            super::HEAVY_TIMEOUT
1187        );
1188        assert_eq!(
1189            super::shell_timeout("git commit -m 'wip'"),
1190            super::HEAVY_TIMEOUT
1191        );
1192        assert_eq!(
1193            super::shell_timeout("git push origin main"),
1194            super::HEAVY_TIMEOUT
1195        );
1196        assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1197        assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1198
1199        // Per-tier env overrides win over the built-in constants. (Non-round
1200        // second values keep the literals clippy-clean and unambiguous.)
1201        crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1202        assert_eq!(
1203            super::shell_timeout("cargo build"),
1204            std::time::Duration::from_secs(90)
1205        );
1206        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1207
1208        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1209        assert_eq!(
1210            super::shell_timeout("git status"),
1211            std::time::Duration::from_secs(30)
1212        );
1213        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1214
1215        // The universal millisecond override wins over everything.
1216        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1217        assert_eq!(
1218            super::shell_timeout("cargo build"),
1219            std::time::Duration::from_secs(5)
1220        );
1221        assert_eq!(
1222            super::shell_timeout("git status"),
1223            std::time::Duration::from_secs(5)
1224        );
1225        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1226
1227        for (var, saved) in [
1228            ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1229            ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1230            ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1231        ] {
1232            if let Some(v) = saved {
1233                crate::test_env::set_var(var, v);
1234            }
1235        }
1236    }
1237
1238    // Task runners (mise/just) wrap builds and test gates that routinely run
1239    // past the 2-minute default; killing them mid-run loses the whole job.
1240    // They get the heavy ceiling like the underlying build tools they invoke.
1241    #[test]
1242    fn task_runners_get_heavy_ceiling() {
1243        let _lock = crate::core::data_dir::test_env_lock();
1244        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1245        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1246        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1247        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1248
1249        assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1250        assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1251        assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1252
1253        if let Some(v) = saved_ms {
1254            crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1255        }
1256        if let Some(v) = saved_heavy {
1257            crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1258        }
1259    }
1260
1261    // Per-call `timeout_ms` (ctx_shell tool arg): explicit caller intent beats
1262    // the built-in tiers in both directions, absurd values clamp to the 1h
1263    // ceiling, zero is ignored, and the operator's universal env pin stays top.
1264    #[test]
1265    fn per_call_timeout_override_resolves_and_clamps() {
1266        let _lock = crate::core::data_dir::test_env_lock();
1267        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1268        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1269
1270        assert_eq!(
1271            super::shell_timeout_with_override("git status", Some(300_000)),
1272            std::time::Duration::from_mins(5)
1273        );
1274        assert_eq!(
1275            super::shell_timeout_with_override("cargo build", Some(30_000)),
1276            std::time::Duration::from_secs(30)
1277        );
1278        assert_eq!(
1279            super::shell_timeout_with_override("git status", Some(999_000_000)),
1280            std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1281        );
1282        assert_eq!(
1283            super::shell_timeout_with_override("git status", Some(0)),
1284            super::DEFAULT_TIMEOUT
1285        );
1286        assert_eq!(
1287            super::shell_timeout_with_override("git status", None),
1288            super::DEFAULT_TIMEOUT
1289        );
1290
1291        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1292        assert_eq!(
1293            super::shell_timeout_with_override("git status", Some(300_000)),
1294            std::time::Duration::from_secs(5)
1295        );
1296        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1297        if let Some(v) = saved_ms {
1298            crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1299        }
1300    }
1301
1302    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
1303    #[test]
1304    fn allowlist_enforces_in_hook_child_mode() {
1305        // Hook-child wins over everything, even an interactive TTY.
1306        assert!(super::allowlist_must_enforce_inner(true, false, true));
1307        assert!(super::allowlist_must_enforce_inner(true, true, true));
1308    }
1309
1310    #[test]
1311    fn allowlist_enforces_for_non_interactive_callers() {
1312        // Agent/script invocation: stderr is a pipe → enforce.
1313        assert!(super::allowlist_must_enforce_inner(false, false, false));
1314    }
1315
1316    #[test]
1317    fn allowlist_warns_for_interactive_humans() {
1318        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
1319        assert!(!super::allowlist_must_enforce_inner(false, false, true));
1320    }
1321
1322    #[test]
1323    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1324        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
1325        assert!(!super::allowlist_must_enforce_inner(false, true, false));
1326        assert!(super::allowlist_must_enforce_inner(true, true, false));
1327    }
1328}