Skip to main content

lean_ctx/shell/exec/
execution.rs

1use std::io::{self, IsTerminal};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5
6/// Execute a command from pre-split argv without going through `sh -c`.
7/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
8/// already correctly split by the user's shell, so re-serializing them
9/// into a string and re-parsing via `sh -c` would risk mangling complex
10/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
11pub fn exec_argv(args: &[String]) -> i32 {
12    if args.is_empty() {
13        return 127;
14    }
15
16    // Quote-safe join used only for the allowlist/policy *checks*; execution
17    // below still consumes the pre-split argv verbatim (the whole reason `-t`
18    // avoids `sh -c`). Joining first means a single argv element such as
19    // `git status; rm -rf /` is checked as ONE quoted token, never re-parsed.
20    let joined = super::super::platform::join_command(args);
21
22    // #595: unwrap a host command wrapper (eval + cwd snapshot) before any
23    // checks so the real command — not the wrapper — is gated and run. The `-t`
24    // path cannot exec a compound argv, so route the rebuild through `exec`.
25    if let Some(u) = super::super::agent_wrapper::unwrap_agent_wrapper(&joined) {
26        return exec(&u.rebuild());
27    }
28
29    // The `-t` track path is the agent's default shell hook
30    // (`_lc() { lean-ctx -t "$@" }`), so it MUST enforce the same allowlist
31    // boundary as `-c` (see `exec`). Previously it skipped the check entirely,
32    // letting every aliased multi-arg invocation (`_lc git …`) bypass the
33    // restriction that `lean-ctx -c` enforces (GH security audit, finding 1).
34    if let Some(code) = allowlist_gate(&joined) {
35        return code;
36    }
37
38    if super::super::reentry::should_pass_through() {
39        return exec_direct(args);
40    }
41
42    let cfg = config::Config::load();
43    let policy = super::super::output_policy::classify(&joined, &cfg.excluded_commands);
44
45    if policy.is_protected() {
46        let code = exec_direct(args);
47        crate::core::tool_lifecycle::record_shell_command(0, 0);
48        return code;
49    }
50
51    let code = exec_direct(args);
52    crate::core::tool_lifecycle::record_shell_command(0, 0);
53    code
54}
55
56fn exec_direct(args: &[String]) -> i32 {
57    let mut cmd = Command::new(&args[0]);
58    cmd.args(&args[1..])
59        .stdin(Stdio::inherit())
60        .stdout(Stdio::inherit())
61        .stderr(Stdio::inherit());
62    super::super::reentry::mark_child(&mut cmd);
63    super::super::platform::apply_utf8_locale(&mut cmd);
64    let status = cmd.status();
65
66    match status {
67        Ok(s) => s.code().unwrap_or(1),
68        Err(e) => {
69            tracing::error!("lean-ctx: failed to execute: {e}");
70            127
71        }
72    }
73}
74
75/// Decides whether an allowlist violation on the CLI path blocks (exit 126) or
76/// only warns.
77///
78/// Enforced when:
79/// - hook-child mode (`LEAN_CTX_HOOK_CHILD`): lean-ctx is the agent's
80///   command-interception channel and must not be weaker than the MCP path, or
81/// - stderr is not a TTY: a non-interactive caller is an agent or script, and
82///   agent-driven `lean-ctx -c` must enforce the same boundary as ctx_shell.
83///
84/// Warn-only when a human runs `lean-ctx -c` at an interactive terminal (they
85/// can run the command without lean-ctx anyway, so blocking adds friction, not
86/// a boundary) or when `LEAN_CTX_ALLOWLIST_WARN_ONLY=1` explicitly opts out.
87fn allowlist_must_enforce() -> bool {
88    let hook_child = crate::core::runtime_flags::hook_child_enabled();
89    let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
90        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
91    allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
92}
93
94/// Pure decision core of [`allowlist_must_enforce`] (unit-testable without
95/// process-global env/TTY state).
96fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
97    if hook_child {
98        return true;
99    }
100    if warn_only {
101        return false;
102    }
103    !stderr_is_tty
104}
105
106/// True when this process's stdout is a **regular file** — i.e. the caller
107/// redirected output to a file (`cmd > out`, `cmd >> out`).
108///
109/// Output captured to a file is consumed as *data*, so it must stay byte-faithful:
110/// compression would silently drop/abbreviate lines and corrupt the file
111/// (e.g. `git status --short > files.txt` losing entries). Pipes (agent capture)
112/// and TTYs are NOT regular files and return `false`, so they keep their normal
113/// behavior — this only ever *adds* a verbatim guarantee, never removes one.
114///
115/// Uses only `std`: it wraps the existing stdout descriptor in a `ManuallyDrop`
116/// `File` purely to read its metadata (`fstat` on Unix, `GetFileInformation` on
117/// Windows) without ever closing the real stdout.
118fn stdout_is_regular_file() -> bool {
119    #[cfg(unix)]
120    {
121        use std::os::unix::io::{AsRawFd, FromRawFd};
122        let fd = io::stdout().as_raw_fd();
123        // SAFETY: fd 1 stays valid for the whole process. `ManuallyDrop` prevents
124        // the wrapper's `Drop` from closing stdout; we only read metadata.
125        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
126        file.metadata().is_ok_and(|m| m.is_file())
127    }
128    #[cfg(windows)]
129    {
130        use std::os::windows::io::{AsRawHandle, FromRawHandle};
131        let handle = io::stdout().as_raw_handle();
132        // SAFETY: the stdout handle stays valid for the whole process.
133        // `ManuallyDrop` prevents the wrapper's `Drop` from closing it.
134        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
135        file.metadata().is_ok_and(|m| m.is_file())
136    }
137    #[cfg(not(any(unix, windows)))]
138    {
139        false
140    }
141}
142
143/// Quote-aware check for stdout file redirects (`>`, `>>`) inside a command
144/// string. Returns `true` when the command redirects to a real file (not
145/// `/dev/null`, not `>&N` fd-duplication, not `2>`).
146fn command_has_file_redirect(cmd: &str) -> bool {
147    let bytes = cmd.as_bytes();
148    let len = bytes.len();
149    let mut i = 0;
150    let mut in_single_quote = false;
151    let mut in_double_quote = false;
152
153    while i < len {
154        let c = bytes[i];
155        if c == b'\\' && !in_single_quote {
156            i += 2;
157            continue;
158        }
159        if c == b'\'' && !in_double_quote {
160            in_single_quote = !in_single_quote;
161        } else if c == b'"' && !in_single_quote {
162            in_double_quote = !in_double_quote;
163        } else if c == b'>' && !in_single_quote && !in_double_quote {
164            if i > 0 && bytes[i - 1] == b'2' {
165                i += 1;
166                continue;
167            }
168            let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
169                i + 2
170            } else {
171                i + 1
172            };
173            let target: String = cmd[target_start..]
174                .trim_start()
175                .chars()
176                .take_while(|c| !c.is_whitespace())
177                .collect();
178            if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
179                i += 1;
180                continue;
181            }
182            if let Some(fd) = target.strip_prefix('&')
183                && !fd.is_empty()
184                && (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
185            {
186                i += 1;
187                continue;
188            }
189            if !target.is_empty() {
190                return true;
191            }
192        }
193        i += 1;
194    }
195    false
196}
197
198/// Shared allowlist gate for the CLI shell entrypoints — `-c` (via [`exec`]) and
199/// `-t` (via [`exec_argv`]). Both must apply the SAME boundary so the track path
200/// (the default shell hook) cannot be weaker than the compress path.
201///
202/// Returns `Some(126)` when the command is blocked and the caller must return
203/// that exit code; `None` when execution may proceed (allowed, or warn-only for
204/// an interactive human — see [`allowlist_must_enforce`]).
205fn allowlist_gate(command: &str) -> Option<i32> {
206    if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
207        if allowlist_must_enforce() {
208            eprintln!("{msg}");
209            eprintln!(
210                "lean-ctx: command blocked by shell allowlist. \
211                 Allow it permanently: lean-ctx allow <cmd> — or set \
212                 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
213            );
214            return Some(126);
215        }
216        // Diagnostic, not user feedback: an interactive human at a TTY can run
217        // the command without lean-ctx anyway, and surfacing a WARN in their
218        // plain terminal is exactly the confusion GH #699 reported. Keep the
219        // warning for non-TTY callers (agents that opted into warn-only).
220        if io::stderr().is_terminal() {
221            tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
222        } else {
223            tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
224        }
225    }
226    None
227}
228
229pub fn exec(command: &str) -> i32 {
230    // #595: when the agent wraps its command in host scaffolding
231    // (`… && eval '<cmd>' … && pwd -P >| …-cwd`), look through it so the allowlist
232    // and compression act on the REAL command, not the wrapper — whose `eval` the
233    // allowlist would otherwise hard-block on every single call. The cwd snapshot
234    // is preserved so the host keeps tracking the working directory.
235    let unwrapped = super::super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
236    let mut collapsed_nested = false;
237    let collapsed;
238    let command = unwrapped.as_deref().unwrap_or(command);
239    let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
240        collapsed_nested = true;
241        collapsed = c;
242        collapsed.as_str()
243    } else {
244        command
245    };
246
247    if let Some(code) = allowlist_gate(command) {
248        return code;
249    }
250
251    let (shell, shell_flag) = super::super::platform::shell_and_flag();
252    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
253    let command = super::super::platform::zsh_safe_command(&command, &shell);
254    let command = command.as_str();
255
256    if super::super::reentry::is_disabled() {
257        return exec_inherit(command, &shell, &shell_flag);
258    }
259    if should_delegate_wrapped_to_shell_default(collapsed_nested) {
260        return exec_shell_default(command, &shell, &shell_flag);
261    }
262
263    let cfg = config::Config::load();
264    let force_compress = crate::core::runtime_flags::compress_enabled();
265    let raw_mode = crate::core::runtime_flags::raw_enabled();
266
267    if raw_mode {
268        return exec_inherit_tracked(command, &shell, &shell_flag);
269    }
270
271    let policy = super::super::output_policy::classify(command, &cfg.excluded_commands);
272
273    // Passthrough: ALWAYS bypass compression, even with force_compress.
274    if policy == super::super::output_policy::OutputPolicy::Passthrough {
275        return exec_inherit_tracked(command, &shell, &shell_flag);
276    }
277
278    // Verbatim: bypass compression unless force_compress is set,
279    // in which case use buffered path (compress_if_beneficial will
280    // respect the verbatim classification and only size-cap).
281    if policy == super::super::output_policy::OutputPolicy::Verbatim && !force_compress {
282        return exec_inherit_tracked(command, &shell, &shell_flag);
283    }
284
285    if !force_compress {
286        if io::stdout().is_terminal() {
287            return exec_inherit_tracked(command, &shell, &shell_flag);
288        }
289        let code = exec_inherit(command, &shell, &shell_flag);
290        crate::core::tool_lifecycle::record_shell_command(0, 0);
291        return code;
292    }
293
294    // Compression is forced (`-c` / LEAN_CTX_COMPRESS, e.g. the agent shell hook).
295    // It must STILL never alter bytes destined for a file: a redirect
296    // (`cmd > out`, `cmd >> out`) means the output is captured as data, not read by
297    // a human or agent. Writing the compressed digest there would silently
298    // drop/abbreviate lines and corrupt the file (e.g. contradictory `git diff`
299    // dumps). Pass redirected-to-file output through verbatim; pipes (agent
300    // capture) and TTYs keep compressing. This is the single choke point, so it
301    // holds for every caller (hook, direct CLI, Pi/MCP bridges).
302    if stdout_is_regular_file() {
303        return exec_inherit_tracked(command, &shell, &shell_flag);
304    }
305
306    // Also bypass compression when the redirect is INSIDE the command string
307    // (e.g., `lean-ctx -c 'git show HEAD:f > out.md'`). In this case lean-ctx's
308    // own stdout is a pipe (to the agent), but the shell child redirects its
309    // stdout to the file. exec_buffered would capture empty/minimal output while
310    // the file gets correct data — but the overhead is wasted and the compressed
311    // empty output confuses agents. Let sh handle it natively. (#1303)
312    if command_has_file_redirect(command) {
313        return exec_inherit_tracked(command, &shell, &shell_flag);
314    }
315
316    super::super::pipeline::exec_buffered(command, &shell, &shell_flag, &cfg)
317}
318
319fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
320    let mut current = command.trim().to_string();
321    let mut changed = false;
322
323    while let Some(next) = strip_one_lean_ctx_exec(&current) {
324        if next == current {
325            break;
326        }
327        current = next;
328        changed = true;
329    }
330
331    changed.then_some(current)
332}
333
334fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
335    // After collapsing `lean-ctx -c "lean-ctx -c ..."` the current process is the
336    // one compression pass that would otherwise be owned by the shell default.
337    // Delegating again would drop back to raw execution or re-enter the hook.
338    super::super::reentry::is_wrapped() && !collapsed_nested
339}
340
341fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
342    let words = split_simple_shell_words(command)?;
343    if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
344        return None;
345    }
346    if words[1].value != "-c" && words[1].value != "exec" {
347        return None;
348    }
349    if words[2..].iter().any(|w| {
350        matches!(
351            w.value.as_str(),
352            "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
353        )
354    }) {
355        return None;
356    }
357    if words.len() == 3 {
358        Some(words[2].value.trim().to_string())
359    } else {
360        Some(command[words[2].start..].trim().to_string())
361    }
362}
363
364fn is_lean_ctx_bin(word: &str) -> bool {
365    std::path::Path::new(word)
366        .file_name()
367        .and_then(|name| name.to_str())
368        .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
369}
370
371struct SimpleShellWord {
372    value: String,
373    start: usize,
374}
375
376fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
377    let mut words = Vec::new();
378    let mut current = String::new();
379    let mut current_start: Option<usize> = None;
380    let mut chars = command.char_indices().peekable();
381    let mut quote: Option<char> = None;
382
383    while let Some((idx, ch)) = chars.next() {
384        match quote {
385            Some('\'') if ch == '\'' => quote = None,
386            Some('"') if ch == '"' => quote = None,
387            None if ch == '\'' || ch == '"' => {
388                current_start.get_or_insert(idx);
389                quote = Some(ch);
390            }
391            Some('"') | None if ch == '\\' => {
392                current_start.get_or_insert(idx);
393                if let Some((_, next)) = chars.next() {
394                    current.push(next);
395                }
396            }
397            None if ch.is_whitespace() => {
398                if let Some(start) = current_start.take() {
399                    words.push(SimpleShellWord {
400                        value: std::mem::take(&mut current),
401                        start,
402                    });
403                }
404            }
405            Some(_) | None => {
406                current_start.get_or_insert(idx);
407                current.push(ch);
408            }
409        }
410    }
411
412    if quote.is_some() {
413        return None;
414    }
415    if let Some(start) = current_start {
416        words.push(SimpleShellWord {
417            value: current,
418            start,
419        });
420    }
421    (!words.is_empty()).then_some(words)
422}
423
424fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
425    let mut cmd = Command::new(shell);
426    cmd.arg(shell_flag)
427        .arg(command)
428        .stdin(Stdio::inherit())
429        .stdout(Stdio::inherit())
430        .stderr(Stdio::inherit());
431    super::super::reentry::mark_child(&mut cmd);
432    super::super::platform::apply_utf8_locale(&mut cmd);
433    super::super::platform::apply_profile_free_env(&mut cmd);
434    let status = cmd.status();
435
436    match status {
437        Ok(s) => s.code().unwrap_or(1),
438        Err(e) => {
439            tracing::error!("lean-ctx: failed to execute: {e}");
440            127
441        }
442    }
443}
444
445fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
446    let mut cmd = Command::new(shell);
447    cmd.arg(shell_flag)
448        .arg(command)
449        .stdin(Stdio::inherit())
450        .stdout(Stdio::inherit())
451        .stderr(Stdio::inherit());
452    super::super::reentry::clear_shell_default_markers(&mut cmd);
453    super::super::platform::apply_utf8_locale(&mut cmd);
454    super::super::platform::apply_profile_free_env(&mut cmd);
455    let status = cmd.status();
456
457    match status {
458        Ok(s) => s.code().unwrap_or(1),
459        Err(e) => {
460            eprintln!("lean-ctx: failed to execute '{command}': {e}");
461            127
462        }
463    }
464}
465
466fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
467    let code = exec_inherit(command, shell, shell_flag);
468    crate::core::tool_lifecycle::record_shell_command(0, 0);
469    code
470}
471
472/// Label inserted between stdout and stderr of a FAILED command so the agent can
473/// attribute the error to the right stream instead of guessing — and never has to
474/// re-run the command raw just to locate the failure. See #809 / #812.
475pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
476
477/// Join captured stdout and stderr for display/recovery. On failure (non-zero
478/// exit) with both streams present, a labeled delimiter separates them; success
479/// output keeps the plain `stdout\nstderr` shape (determinism, #498).
480pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
481    match (stdout.is_empty(), stderr.is_empty()) {
482        (_, true) => stdout.to_string(),
483        (true, false) => stderr.to_string(),
484        (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
485        (false, false) => format!("{stdout}\n{stderr}"),
486    }
487}
488
489// Buffered command execution and output transformation live in `pipeline`.
490
491#[cfg(test)]
492mod nested_lean_ctx_exec_tests;
493
494#[cfg(test)]
495mod exec_tests {
496    #[test]
497    fn combine_streams_labels_stderr_on_failure() {
498        let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
499        assert_eq!(
500            out,
501            format!(
502                "build ok\n{}\nlinker: undefined symbol",
503                super::STDERR_LABEL
504            )
505        );
506    }
507
508    #[test]
509    fn combine_streams_plain_join_on_success() {
510        let out = super::combine_streams("step 1", "warning: noop", 0);
511        assert_eq!(out, "step 1\nwarning: noop");
512        assert!(!out.contains(super::STDERR_LABEL));
513    }
514
515    #[test]
516    fn combine_streams_single_stream_is_unchanged() {
517        assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
518        assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
519    }
520
521    #[test]
522    fn exec_direct_runs_true() {
523        let code = super::exec_direct(&["true".to_string()]);
524        assert_eq!(code, 0);
525    }
526
527    #[test]
528    fn exec_direct_runs_false() {
529        let code = super::exec_direct(&["false".to_string()]);
530        assert_ne!(code, 0);
531    }
532
533    #[test]
534    fn exec_direct_preserves_args_with_special_chars() {
535        let code = super::exec_direct(&[
536            "echo".to_string(),
537            "hello world".to_string(),
538            "it's here".to_string(),
539            "a \"quoted\" thing".to_string(),
540        ]);
541        assert_eq!(code, 0);
542    }
543
544    #[test]
545    fn exec_direct_nonexistent_returns_127() {
546        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
547        assert_eq!(code, 127);
548    }
549
550    #[test]
551    fn exec_argv_empty_returns_127() {
552        let code = super::exec_argv(&[]);
553        assert_eq!(code, 127);
554    }
555
556    #[test]
557    fn exec_argv_runs_simple_command() {
558        let _lock = crate::core::data_dir::test_env_lock();
559        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
560        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
561        let code = super::exec_argv(&["true".to_string()]);
562        assert_eq!(code, 0);
563    }
564
565    #[test]
566    fn exec_argv_passes_through_when_disabled() {
567        let _lock = crate::core::data_dir::test_env_lock();
568        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
569        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
570        let code = super::exec_argv(&["true".to_string()]);
571        crate::test_env::remove_var("LEAN_CTX_DISABLED");
572        assert_eq!(code, 0);
573    }
574
575    // Finding 1 (GH security audit): the `-t` track path is the default shell
576    // hook, so it must enforce the allowlist exactly like the `-c` path. A
577    // non-allowlisted command must be blocked (126), not executed.
578    #[test]
579    fn exec_argv_enforces_allowlist_for_disallowed_command() {
580        let _lock = crate::core::data_dir::test_env_lock();
581        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
582        crate::test_env::remove_var("LEAN_CTX_DISABLED");
583        crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
584        // hook-child forces enforcement regardless of the test runner's TTY state.
585        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
586        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
587
588        // #1022: `true` is now a SHELL_BUILTIN (bypasses allowlist).
589        // Use `xxd` which is a real binary and not in the override list.
590        let code = super::exec_argv(&["xxd".to_string()]);
591
592        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
593        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
594
595        assert_eq!(
596            code, 126,
597            "non-allowlisted command must be blocked on the -t track path"
598        );
599    }
600
601    #[test]
602    fn exec_argv_allows_allowlisted_command() {
603        let _lock = crate::core::data_dir::test_env_lock();
604        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
605        crate::test_env::remove_var("LEAN_CTX_DISABLED");
606        crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
607        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
608
609        let code = super::exec_argv(&["true".to_string()]);
610
611        crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
612        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
613
614        assert_eq!(code, 0, "allowlisted command must run on the -t track path");
615    }
616    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
617    #[test]
618    fn allowlist_enforces_in_hook_child_mode() {
619        // Hook-child wins over everything, even an interactive TTY.
620        assert!(super::allowlist_must_enforce_inner(true, false, true));
621        assert!(super::allowlist_must_enforce_inner(true, true, true));
622    }
623
624    #[test]
625    fn allowlist_enforces_for_non_interactive_callers() {
626        // Agent/script invocation: stderr is a pipe → enforce.
627        assert!(super::allowlist_must_enforce_inner(false, false, false));
628    }
629
630    #[test]
631    fn allowlist_warns_for_interactive_humans() {
632        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
633        assert!(!super::allowlist_must_enforce_inner(false, false, true));
634    }
635
636    #[test]
637    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
638        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
639        assert!(!super::allowlist_must_enforce_inner(false, true, false));
640        assert!(super::allowlist_must_enforce_inner(true, true, false));
641    }
642
643    // --- #1303: command_has_file_redirect ---
644
645    #[test]
646    fn redirect_to_file_detected() {
647        assert!(super::command_has_file_redirect("git show HEAD:f > out.md"));
648        assert!(super::command_has_file_redirect("git diff >> changes.log"));
649        assert!(super::command_has_file_redirect(
650            "git status > /tmp/status.txt"
651        ));
652    }
653
654    #[test]
655    fn no_redirect_not_detected() {
656        assert!(!super::command_has_file_redirect("git status"));
657        assert!(!super::command_has_file_redirect("cargo test --lib"));
658    }
659
660    #[test]
661    fn dev_null_not_detected_as_redirect() {
662        assert!(!super::command_has_file_redirect("cargo test > /dev/null"));
663        assert!(!super::command_has_file_redirect("cmd > /dev/stdout"));
664        assert!(!super::command_has_file_redirect("cmd > /dev/stderr"));
665    }
666
667    #[test]
668    fn stderr_redirect_not_detected() {
669        assert!(!super::command_has_file_redirect(
670            "cargo test 2> errors.log"
671        ));
672        assert!(!super::command_has_file_redirect("cargo test 2>/dev/null"));
673    }
674
675    #[test]
676    fn fd_dup_not_detected() {
677        assert!(!super::command_has_file_redirect("cargo test 2>&1"));
678        assert!(!super::command_has_file_redirect("cmd >&2"));
679    }
680
681    #[test]
682    fn quoted_redirect_not_detected() {
683        assert!(!super::command_has_file_redirect("echo 'a > b'"));
684        assert!(!super::command_has_file_redirect("echo \"a > b\""));
685        assert!(!super::command_has_file_redirect(
686            "gh pr create --body 'see > details'"
687        ));
688    }
689
690    #[test]
691    fn escaped_redirect_not_detected() {
692        assert!(!super::command_has_file_redirect("echo a \\> b"));
693    }
694}