Skip to main content

lean_ctx/shell/
exec.rs

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