Skip to main content

lean_ctx/shell/
exec.rs

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