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        cmd.stdin(Stdio::null());
834        #[cfg(unix)]
835        {
836            use std::os::unix::process::CommandExt as _;
837            cmd.process_group(0);
838        }
839    }
840    super::reentry::mark_child(&mut cmd);
841    super::platform::apply_utf8_locale(&mut cmd);
842    super::platform::apply_profile_free_env(&mut cmd);
843    let child = cmd.spawn();
844
845    let child = match child {
846        Ok(c) => c,
847        Err(e) => {
848            tracing::error!("lean-ctx: failed to execute: {e}");
849            #[cfg(windows)]
850            if let Some(ref tmp) = ps_tmp_path {
851                let _ = std::fs::remove_file(tmp);
852            }
853            return 127;
854        }
855    };
856
857    let (max_bytes, timeout) = exec_limits(command);
858    let output = wait_with_limits(child, max_bytes, timeout, isolate);
859
860    let duration_ms = start.elapsed().as_millis();
861    let exit_code = output.status.code().unwrap_or(1);
862    let stdout = super::platform::decode_output(&output.stdout);
863    let stderr = super::platform::decode_output(&output.stderr);
864
865    let full_output = combine_streams(&stdout, &stderr, exit_code);
866    let input_tokens = count_tokens(&full_output);
867
868    // Structured diagnostics (#499): failing cargo/tsc/eslint runs mark their
869    // files as context-priority; succeeding runs clear them.
870    crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
871
872    // Gotcha learning: a failing build/test pushes a pending error; the next
873    // green run of the same command base correlates the fix into a gotcha.
874    crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
875
876    let (compressed, output_tokens) =
877        super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
878
879    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
880
881    if !compressed.is_empty() {
882        let _ = io::stdout().write_all(compressed.as_bytes());
883        if !compressed.ends_with('\n') {
884            let _ = io::stdout().write_all(b"\n");
885        }
886    }
887    // Shared tee policy (#811): identical decision on the CLI and MCP paths —
888    // `Failures` keys off the real exit code, not a substring in the output.
889    let should_tee = super::tee_policy::should_tee(
890        &cfg.tee_mode,
891        exit_code,
892        full_output.trim().is_empty(),
893        input_tokens,
894        output_tokens,
895    );
896    if should_tee
897        && let Some(path) = super::redact::save_tee(command, &full_output)
898        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
899    {
900        eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
901    }
902
903    let threshold = cfg.slow_command_threshold_ms;
904    if threshold > 0 && duration_ms >= threshold as u128 {
905        slow_log::record(command, duration_ms, exit_code);
906    }
907
908    #[cfg(windows)]
909    if let Some(ref tmp) = ps_tmp_path {
910        let _ = std::fs::remove_file(tmp);
911    }
912
913    exit_code
914}
915
916#[cfg(test)]
917mod exec_tests {
918    #[test]
919    fn combine_streams_labels_stderr_on_failure() {
920        let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
921        assert_eq!(
922            out,
923            format!(
924                "build ok\n{}\nlinker: undefined symbol",
925                super::STDERR_LABEL
926            )
927        );
928    }
929
930    #[test]
931    fn combine_streams_plain_join_on_success() {
932        let out = super::combine_streams("step 1", "warning: noop", 0);
933        assert_eq!(out, "step 1\nwarning: noop");
934        assert!(!out.contains(super::STDERR_LABEL));
935    }
936
937    #[test]
938    fn combine_streams_single_stream_is_unchanged() {
939        assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
940        assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
941    }
942
943    #[test]
944    fn exec_direct_runs_true() {
945        let code = super::exec_direct(&["true".to_string()]);
946        assert_eq!(code, 0);
947    }
948
949    #[test]
950    fn exec_direct_runs_false() {
951        let code = super::exec_direct(&["false".to_string()]);
952        assert_ne!(code, 0);
953    }
954
955    #[test]
956    fn exec_direct_preserves_args_with_special_chars() {
957        let code = super::exec_direct(&[
958            "echo".to_string(),
959            "hello world".to_string(),
960            "it's here".to_string(),
961            "a \"quoted\" thing".to_string(),
962        ]);
963        assert_eq!(code, 0);
964    }
965
966    #[test]
967    fn exec_direct_nonexistent_returns_127() {
968        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
969        assert_eq!(code, 127);
970    }
971
972    #[test]
973    fn exec_argv_empty_returns_127() {
974        let code = super::exec_argv(&[]);
975        assert_eq!(code, 127);
976    }
977
978    #[test]
979    fn exec_argv_runs_simple_command() {
980        let _lock = crate::core::data_dir::test_env_lock();
981        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
982        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
983        let code = super::exec_argv(&["true".to_string()]);
984        assert_eq!(code, 0);
985    }
986
987    #[test]
988    fn exec_argv_passes_through_when_disabled() {
989        let _lock = crate::core::data_dir::test_env_lock();
990        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
991        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
992        let code = super::exec_argv(&["true".to_string()]);
993        crate::test_env::remove_var("LEAN_CTX_DISABLED");
994        assert_eq!(code, 0);
995    }
996
997    // Finding 1 (GH security audit): the `-t` track path is the default shell
998    // hook, so it must enforce the allowlist exactly like the `-c` path. A
999    // non-allowlisted command must be blocked (126), not executed.
1000    #[test]
1001    fn exec_argv_enforces_allowlist_for_disallowed_command() {
1002        let _lock = crate::core::data_dir::test_env_lock();
1003        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1004        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1005        crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
1006        // hook-child forces enforcement regardless of the test runner's TTY state.
1007        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1008        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
1009
1010        let code = super::exec_argv(&["true".to_string()]);
1011
1012        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1013        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1014
1015        assert_eq!(
1016            code, 126,
1017            "non-allowlisted command must be blocked on the -t track path"
1018        );
1019    }
1020
1021    #[test]
1022    fn exec_argv_allows_allowlisted_command() {
1023        let _lock = crate::core::data_dir::test_env_lock();
1024        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1025        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1026        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
1027        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
1028
1029        let code = super::exec_argv(&["true".to_string()]);
1030
1031        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
1032        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
1033
1034        assert_eq!(code, 0, "allowlisted command must run on the -t track path");
1035    }
1036
1037    #[test]
1038    fn wait_with_limits_captures_output() {
1039        let child = std::process::Command::new("echo")
1040            .arg("hello")
1041            .stdout(std::process::Stdio::piped())
1042            .stderr(std::process::Stdio::piped())
1043            .spawn()
1044            .unwrap();
1045
1046        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5), false);
1047        let stdout = String::from_utf8_lossy(&output.stdout);
1048        assert!(
1049            stdout.contains("hello"),
1050            "expected 'hello' in output: {stdout}"
1051        );
1052        assert!(output.status.success());
1053    }
1054
1055    #[test]
1056    fn wait_with_limits_truncates_large_output() {
1057        // Generate ~100 KB of output, limit to 1 KB
1058        let child = std::process::Command::new("sh")
1059            .args(["-c", "yes 'aaaa' | head -25000"])
1060            .stdout(std::process::Stdio::piped())
1061            .stderr(std::process::Stdio::piped())
1062            .spawn()
1063            .unwrap();
1064
1065        let output =
1066            super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10), false);
1067        let stdout = String::from_utf8_lossy(&output.stdout);
1068        assert!(
1069            stdout.contains("[lean-ctx: output truncated"),
1070            "expected truncation notice, got len={}: ...{}",
1071            stdout.len(),
1072            &stdout[stdout.len().saturating_sub(80)..]
1073        );
1074    }
1075
1076    #[test]
1077    fn wait_with_limits_timeout_kills_process() {
1078        let child = std::process::Command::new("sleep")
1079            .arg("60")
1080            .stdout(std::process::Stdio::piped())
1081            .stderr(std::process::Stdio::piped())
1082            .spawn()
1083            .unwrap();
1084
1085        let start = std::time::Instant::now();
1086        let output =
1087            super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), false);
1088        let elapsed = start.elapsed();
1089
1090        assert!(
1091            elapsed < std::time::Duration::from_secs(3),
1092            "timeout should kill quickly, took {elapsed:?}"
1093        );
1094        let stdout = String::from_utf8_lossy(&output.stdout);
1095        assert!(stdout.contains("[lean-ctx: output truncated"));
1096    }
1097
1098    /// GH #720: killing only the direct child (a shell) on timeout leaves its
1099    /// grandchildren alive holding the stdout pipe — the reader threads never
1100    /// see EOF and `wait_with_limits` blocks forever even though the timeout
1101    /// fired. With the child in its own process group and a group kill, the
1102    /// whole tree dies and the call returns promptly.
1103    #[cfg(unix)]
1104    #[test]
1105    fn wait_with_limits_group_kill_reaps_grandchildren() {
1106        use std::os::unix::process::CommandExt as _;
1107        // The shell spawns a grandchild that inherits stdout and sleeps far
1108        // beyond the timeout; the shell itself also sleeps so the timeout path
1109        // (not natural exit) is exercised.
1110        let mut cmd = std::process::Command::new("sh");
1111        cmd.args(["-c", "sleep 30 & sleep 30"])
1112            .stdin(std::process::Stdio::null())
1113            .stdout(std::process::Stdio::piped())
1114            .stderr(std::process::Stdio::piped());
1115        cmd.process_group(0);
1116        let child = cmd.spawn().unwrap();
1117        let pgid = child.id() as libc::pid_t;
1118
1119        let start = std::time::Instant::now();
1120        let _ = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200), true);
1121        let elapsed = start.elapsed();
1122
1123        assert!(
1124            elapsed < std::time::Duration::from_secs(5),
1125            "group kill must unblock the reader threads, took {elapsed:?}"
1126        );
1127        // The whole group must be gone (ESRCH), not just the direct child.
1128        // A brief grace period lets the kernel finish reaping.
1129        let mut group_gone = false;
1130        for _ in 0..50 {
1131            // SAFETY: signal 0 only probes for existence.
1132            if unsafe { libc::killpg(pgid, 0) } == -1 {
1133                group_gone = true;
1134                break;
1135            }
1136            std::thread::sleep(std::time::Duration::from_millis(20));
1137        }
1138        assert!(group_gone, "process group {pgid} must be fully reaped");
1139    }
1140
1141    #[test]
1142    fn heavy_commands_get_higher_byte_limits() {
1143        // exec_limits owns the byte ceiling; timeout resolution is covered by
1144        // `shell_timeout_resolves_heavy_normal_and_env_overrides` (which is
1145        // env/config-isolated, so these stay deterministic regardless of the
1146        // operator's config.toml).
1147        for cmd in [
1148            "cargo build --release",
1149            "cargo test --lib",
1150            "cargo nextest run",
1151            "npm run build",
1152            "docker build -t myapp .",
1153            // Git verbs that fire build/test hooks (pre-commit clippy, pre-push
1154            // preflight) must not be killed at the default ceiling (#854).
1155            "git commit --amend --no-edit",
1156            "git push -u origin HEAD",
1157            // Agents prefix with `cd /path && ...` — heavy detection must
1158            // look through it to avoid 120s timeout on builds.
1159            "cd /some/path && cargo test --lib",
1160            "cd /foo/bar && cargo build --release",
1161            "cd /workspace; npm ci",
1162        ] {
1163            let (bytes, _) = super::exec_limits(cmd);
1164            assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
1165        }
1166    }
1167
1168    #[test]
1169    fn normal_commands_get_default_byte_limits() {
1170        // Read-only git verbs stay on the default ceiling — only `commit`/`push`
1171        // (which fire the cargo-heavy hooks) are promoted.
1172        for cmd in ["echo hello", "git status", "git log --oneline -5"] {
1173            let (bytes, _) = super::exec_limits(cmd);
1174            assert_eq!(
1175                bytes,
1176                super::DEFAULT_MAX_BYTES,
1177                "default byte limit for {cmd}"
1178            );
1179        }
1180    }
1181
1182    #[test]
1183    fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
1184        // Serialize env mutation so this never races other env-reading tests.
1185        let _lock = crate::core::data_dir::test_env_lock();
1186        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1187        let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
1188        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1189        for v in [
1190            "LEAN_CTX_SHELL_TIMEOUT_MS",
1191            "LEAN_CTX_SHELL_TIMEOUT_SECS",
1192            "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
1193        ] {
1194            crate::test_env::remove_var(v);
1195        }
1196
1197        // Heavy builds/tests and hook-firing git verbs get the heavy ceiling;
1198        // read-only verbs stay on the default. Preserves the #854 promotion.
1199        assert_eq!(
1200            super::shell_timeout("cargo install --path ."),
1201            super::HEAVY_TIMEOUT
1202        );
1203        assert_eq!(
1204            super::shell_timeout("cargo nextest run"),
1205            super::HEAVY_TIMEOUT
1206        );
1207        assert_eq!(
1208            super::shell_timeout("git commit -m 'wip'"),
1209            super::HEAVY_TIMEOUT
1210        );
1211        assert_eq!(
1212            super::shell_timeout("git push origin main"),
1213            super::HEAVY_TIMEOUT
1214        );
1215        assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
1216        assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
1217        // `cd ... && heavy` must resolve to HEAVY so agents don't get killed at 120s.
1218        assert_eq!(
1219            super::shell_timeout("cd /some/project && cargo test --lib"),
1220            super::HEAVY_TIMEOUT
1221        );
1222        assert_eq!(
1223            super::shell_timeout("cd /workspace && cargo build --release"),
1224            super::HEAVY_TIMEOUT
1225        );
1226        assert_eq!(
1227            super::shell_timeout("cd /app; npm ci"),
1228            super::HEAVY_TIMEOUT
1229        );
1230
1231        // Per-tier env overrides win over the built-in constants. (Non-round
1232        // second values keep the literals clippy-clean and unambiguous.)
1233        crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
1234        assert_eq!(
1235            super::shell_timeout("cargo build"),
1236            std::time::Duration::from_secs(90)
1237        );
1238        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1239
1240        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
1241        assert_eq!(
1242            super::shell_timeout("git status"),
1243            std::time::Duration::from_secs(30)
1244        );
1245        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
1246
1247        // The universal millisecond override wins over everything.
1248        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1249        assert_eq!(
1250            super::shell_timeout("cargo build"),
1251            std::time::Duration::from_secs(5)
1252        );
1253        assert_eq!(
1254            super::shell_timeout("git status"),
1255            std::time::Duration::from_secs(5)
1256        );
1257        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1258
1259        for (var, saved) in [
1260            ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
1261            ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
1262            ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
1263        ] {
1264            if let Some(v) = saved {
1265                crate::test_env::set_var(var, v);
1266            }
1267        }
1268    }
1269
1270    // Task runners (mise/just) wrap builds and test gates that routinely run
1271    // past the 2-minute default; killing them mid-run loses the whole job.
1272    // They get the heavy ceiling like the underlying build tools they invoke.
1273    #[test]
1274    fn task_runners_get_heavy_ceiling() {
1275        let _lock = crate::core::data_dir::test_env_lock();
1276        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1277        let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
1278        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1279        crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
1280
1281        assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
1282        assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
1283        assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
1284
1285        if let Some(v) = saved_ms {
1286            crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1287        }
1288        if let Some(v) = saved_heavy {
1289            crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
1290        }
1291    }
1292
1293    // Per-call `timeout_ms` (ctx_shell tool arg): explicit caller intent beats
1294    // the built-in tiers in both directions, absurd values clamp to the 1h
1295    // ceiling, zero is ignored, and the operator's universal env pin stays top.
1296    #[test]
1297    fn per_call_timeout_override_resolves_and_clamps() {
1298        let _lock = crate::core::data_dir::test_env_lock();
1299        let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
1300        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1301
1302        assert_eq!(
1303            super::shell_timeout_with_override("git status", Some(300_000)),
1304            std::time::Duration::from_mins(5)
1305        );
1306        assert_eq!(
1307            super::shell_timeout_with_override("cargo build", Some(30_000)),
1308            std::time::Duration::from_secs(30)
1309        );
1310        assert_eq!(
1311            super::shell_timeout_with_override("git status", Some(999_000_000)),
1312            std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
1313        );
1314        assert_eq!(
1315            super::shell_timeout_with_override("git status", Some(0)),
1316            super::DEFAULT_TIMEOUT
1317        );
1318        assert_eq!(
1319            super::shell_timeout_with_override("git status", None),
1320            super::DEFAULT_TIMEOUT
1321        );
1322
1323        crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
1324        assert_eq!(
1325            super::shell_timeout_with_override("git status", Some(300_000)),
1326            std::time::Duration::from_secs(5)
1327        );
1328        crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1329        if let Some(v) = saved_ms {
1330            crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
1331        }
1332    }
1333
1334    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
1335    #[test]
1336    fn allowlist_enforces_in_hook_child_mode() {
1337        // Hook-child wins over everything, even an interactive TTY.
1338        assert!(super::allowlist_must_enforce_inner(true, false, true));
1339        assert!(super::allowlist_must_enforce_inner(true, true, true));
1340    }
1341
1342    #[test]
1343    fn allowlist_enforces_for_non_interactive_callers() {
1344        // Agent/script invocation: stderr is a pipe → enforce.
1345        assert!(super::allowlist_must_enforce_inner(false, false, false));
1346    }
1347
1348    #[test]
1349    fn allowlist_warns_for_interactive_humans() {
1350        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
1351        assert!(!super::allowlist_must_enforce_inner(false, false, true));
1352    }
1353
1354    #[test]
1355    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
1356        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
1357        assert!(!super::allowlist_must_enforce_inner(false, true, false));
1358        assert!(super::allowlist_must_enforce_inner(true, true, false));
1359    }
1360}