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
103const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; // 8 MB
104const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
105const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MB
106const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
107
108fn exec_limits(command: &str) -> (usize, std::time::Duration) {
109    if is_heavy_command(command) {
110        (HEAVY_MAX_BYTES, HEAVY_TIMEOUT)
111    } else {
112        (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT)
113    }
114}
115
116fn is_heavy_command(command: &str) -> bool {
117    let cmd = command.trim();
118    let lower = cmd.to_lowercase();
119    static HEAVY_PREFIXES: &[&str] = &[
120        "cargo build",
121        "cargo test",
122        "cargo clippy",
123        "cargo check",
124        "cargo install",
125        "cargo bench",
126        "npm run build",
127        "npm install",
128        "npm ci",
129        "pnpm install",
130        "pnpm build",
131        "yarn install",
132        "yarn build",
133        "bun install",
134        "make",
135        "cmake",
136        "bazel build",
137        "bazel test",
138        "gradle build",
139        "gradle test",
140        "mvn package",
141        "mvn install",
142        "mvn test",
143        "go build",
144        "go test",
145        "dotnet build",
146        "dotnet test",
147        "swift build",
148        "swift test",
149        "flutter build",
150        "docker build",
151        "docker compose build",
152        "pip install",
153        "poetry install",
154        "uv sync",
155        "bundle install",
156        "mix compile",
157    ];
158    HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
159}
160
161/// Execute a command from pre-split argv without going through `sh -c`.
162/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
163/// already correctly split by the user's shell, so re-serializing them
164/// into a string and re-parsing via `sh -c` would risk mangling complex
165/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
166pub fn exec_argv(args: &[String]) -> i32 {
167    if args.is_empty() {
168        return 127;
169    }
170
171    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
172        return exec_direct(args);
173    }
174
175    let joined = super::platform::join_command(args);
176    let cfg = config::Config::load();
177    let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
178
179    if policy.is_protected() {
180        let code = exec_direct(args);
181        crate::core::tool_lifecycle::record_shell_command(0, 0);
182        return code;
183    }
184
185    let code = exec_direct(args);
186    crate::core::tool_lifecycle::record_shell_command(0, 0);
187    code
188}
189
190fn exec_direct(args: &[String]) -> i32 {
191    let mut cmd = Command::new(&args[0]);
192    cmd.args(&args[1..])
193        .env("LEAN_CTX_ACTIVE", "1")
194        .stdin(Stdio::inherit())
195        .stdout(Stdio::inherit())
196        .stderr(Stdio::inherit());
197    super::platform::apply_utf8_locale(&mut cmd);
198    let status = cmd.status();
199
200    match status {
201        Ok(s) => s.code().unwrap_or(1),
202        Err(e) => {
203            tracing::error!("lean-ctx: failed to execute: {e}");
204            127
205        }
206    }
207}
208
209/// Decides whether an allowlist violation on the CLI path blocks (exit 126) or
210/// only warns.
211///
212/// Enforced when:
213/// - hook-child mode (`LEAN_CTX_HOOK_CHILD`): lean-ctx is the agent's
214///   command-interception channel and must not be weaker than the MCP path, or
215/// - stderr is not a TTY: a non-interactive caller is an agent or script, and
216///   agent-driven `lean-ctx -c` must enforce the same boundary as ctx_shell.
217///
218/// Warn-only when a human runs `lean-ctx -c` at an interactive terminal (they
219/// can run the command without lean-ctx anyway, so blocking adds friction, not
220/// a boundary) or when `LEAN_CTX_ALLOWLIST_WARN_ONLY=1` explicitly opts out.
221fn allowlist_must_enforce() -> bool {
222    let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
223    let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
224        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
225    allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
226}
227
228/// Pure decision core of [`allowlist_must_enforce`] (unit-testable without
229/// process-global env/TTY state).
230fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
231    if hook_child {
232        return true;
233    }
234    if warn_only {
235        return false;
236    }
237    !stderr_is_tty
238}
239
240/// True when this process's stdout is a **regular file** — i.e. the caller
241/// redirected output to a file (`cmd > out`, `cmd >> out`).
242///
243/// Output captured to a file is consumed as *data*, so it must stay byte-faithful:
244/// compression would silently drop/abbreviate lines and corrupt the file
245/// (e.g. `git status --short > files.txt` losing entries). Pipes (agent capture)
246/// and TTYs are NOT regular files and return `false`, so they keep their normal
247/// behavior — this only ever *adds* a verbatim guarantee, never removes one.
248///
249/// Uses only `std`: it wraps the existing stdout descriptor in a `ManuallyDrop`
250/// `File` purely to read its metadata (`fstat` on Unix, `GetFileInformation` on
251/// Windows) without ever closing the real stdout.
252fn stdout_is_regular_file() -> bool {
253    #[cfg(unix)]
254    {
255        use std::os::unix::io::{AsRawFd, FromRawFd};
256        let fd = io::stdout().as_raw_fd();
257        // SAFETY: fd 1 stays valid for the whole process. `ManuallyDrop` prevents
258        // the wrapper's `Drop` from closing stdout; we only read metadata.
259        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
260        file.metadata().is_ok_and(|m| m.is_file())
261    }
262    #[cfg(windows)]
263    {
264        use std::os::windows::io::{AsRawHandle, FromRawHandle};
265        let handle = io::stdout().as_raw_handle();
266        // SAFETY: the stdout handle stays valid for the whole process.
267        // `ManuallyDrop` prevents the wrapper's `Drop` from closing it.
268        let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
269        file.metadata().is_ok_and(|m| m.is_file())
270    }
271    #[cfg(not(any(unix, windows)))]
272    {
273        false
274    }
275}
276
277pub fn exec(command: &str) -> i32 {
278    if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
279        if allowlist_must_enforce() {
280            eprintln!("{msg}");
281            eprintln!(
282                "lean-ctx: command blocked by shell allowlist. \
283                 Allow it permanently: lean-ctx allow <cmd> — or set \
284                 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
285            );
286            return 126;
287        }
288        tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
289    }
290
291    let (shell, shell_flag) = super::platform::shell_and_flag();
292    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
293    let command = command.as_str();
294
295    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
296        return exec_inherit(command, &shell, &shell_flag);
297    }
298
299    let cfg = config::Config::load();
300    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
301    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
302
303    if raw_mode {
304        return exec_inherit_tracked(command, &shell, &shell_flag);
305    }
306
307    let policy = super::output_policy::classify(command, &cfg.excluded_commands);
308
309    // Passthrough: ALWAYS bypass compression, even with force_compress.
310    if policy == super::output_policy::OutputPolicy::Passthrough {
311        return exec_inherit_tracked(command, &shell, &shell_flag);
312    }
313
314    // Verbatim: bypass compression unless force_compress is set,
315    // in which case use buffered path (compress_if_beneficial will
316    // respect the verbatim classification and only size-cap).
317    if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
318        return exec_inherit_tracked(command, &shell, &shell_flag);
319    }
320
321    if !force_compress {
322        if io::stdout().is_terminal() {
323            return exec_inherit_tracked(command, &shell, &shell_flag);
324        }
325        let code = exec_inherit(command, &shell, &shell_flag);
326        crate::core::tool_lifecycle::record_shell_command(0, 0);
327        return code;
328    }
329
330    // Compression is forced (`-c` / LEAN_CTX_COMPRESS, e.g. the agent shell hook).
331    // It must STILL never alter bytes destined for a file: a redirect
332    // (`cmd > out`, `cmd >> out`) means the output is captured as data, not read by
333    // a human or agent. Writing the compressed digest there would silently
334    // drop/abbreviate lines and corrupt the file (e.g. contradictory `git diff`
335    // dumps). Pass redirected-to-file output through verbatim; pipes (agent
336    // capture) and TTYs keep compressing. This is the single choke point, so it
337    // holds for every caller (hook, direct CLI, Pi/MCP bridges).
338    if stdout_is_regular_file() {
339        return exec_inherit_tracked(command, &shell, &shell_flag);
340    }
341
342    exec_buffered(command, &shell, &shell_flag, &cfg)
343}
344
345fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
346    let mut cmd = Command::new(shell);
347    cmd.arg(shell_flag)
348        .arg(command)
349        .env("LEAN_CTX_ACTIVE", "1")
350        .stdin(Stdio::inherit())
351        .stdout(Stdio::inherit())
352        .stderr(Stdio::inherit());
353    super::platform::apply_utf8_locale(&mut cmd);
354    let status = cmd.status();
355
356    match status {
357        Ok(s) => s.code().unwrap_or(1),
358        Err(e) => {
359            tracing::error!("lean-ctx: failed to execute: {e}");
360            127
361        }
362    }
363}
364
365fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
366    let code = exec_inherit(command, shell, shell_flag);
367    crate::core::tool_lifecycle::record_shell_command(0, 0);
368    code
369}
370
371fn combine_output(stdout: &str, stderr: &str) -> String {
372    if stderr.is_empty() {
373        stdout.to_string()
374    } else if stdout.is_empty() {
375        stderr.to_string()
376    } else {
377        format!("{stdout}\n{stderr}")
378    }
379}
380
381fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
382    #[cfg(windows)]
383    super::platform::set_console_utf8();
384
385    let start = std::time::Instant::now();
386
387    let mut cmd = Command::new(shell);
388
389    #[cfg(windows)]
390    let ps_tmp_path: Option<tempfile::TempPath>;
391    #[cfg(windows)]
392    {
393        if super::platform::is_powershell(shell) {
394            let ps_script = format!(
395                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
396                command
397            );
398            // A temp script lets us set UTF-8 output encoding. If the temp file
399            // cannot be created (full disk, perms, broken TMP), degrade to
400            // running the command inline rather than panicking the process.
401            match tempfile::Builder::new()
402                .prefix("lean-ctx-ps-")
403                .suffix(".ps1")
404                .tempfile()
405            {
406                Ok(tmp) => {
407                    let tmp_path = tmp.into_temp_path();
408                    let _ = std::fs::write(&tmp_path, &ps_script);
409                    cmd.args([
410                        "-NoProfile",
411                        "-ExecutionPolicy",
412                        "Bypass",
413                        "-File",
414                        &tmp_path.to_string_lossy(),
415                    ]);
416                    ps_tmp_path = Some(tmp_path);
417                }
418                Err(e) => {
419                    tracing::warn!(
420                        "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
421                    );
422                    cmd.arg(shell_flag);
423                    cmd.arg(command);
424                    ps_tmp_path = None;
425                }
426            }
427        } else {
428            cmd.arg(shell_flag);
429            cmd.arg(command);
430            ps_tmp_path = None;
431        }
432    }
433    #[cfg(not(windows))]
434    {
435        cmd.arg(shell_flag);
436        cmd.arg(command);
437    }
438
439    cmd.env("LEAN_CTX_ACTIVE", "1")
440        .stdout(Stdio::piped())
441        .stderr(Stdio::piped());
442    super::platform::apply_utf8_locale(&mut cmd);
443    let child = cmd.spawn();
444
445    let child = match child {
446        Ok(c) => c,
447        Err(e) => {
448            tracing::error!("lean-ctx: failed to execute: {e}");
449            #[cfg(windows)]
450            if let Some(ref tmp) = ps_tmp_path {
451                let _ = std::fs::remove_file(tmp);
452            }
453            return 127;
454        }
455    };
456
457    let (max_bytes, timeout) = exec_limits(command);
458    let output = wait_with_limits(child, max_bytes, timeout);
459
460    let duration_ms = start.elapsed().as_millis();
461    let exit_code = output.status.code().unwrap_or(1);
462    let stdout = super::platform::decode_output(&output.stdout);
463    let stderr = super::platform::decode_output(&output.stderr);
464
465    let full_output = combine_output(&stdout, &stderr);
466    let input_tokens = count_tokens(&full_output);
467
468    // Structured diagnostics (#499): failing cargo/tsc/eslint runs mark their
469    // files as context-priority; succeeding runs clear them.
470    crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
471
472    let (compressed, output_tokens) =
473        super::compress::compress_and_measure(command, &stdout, &stderr);
474
475    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
476
477    if !compressed.is_empty() {
478        let _ = io::stdout().write_all(compressed.as_bytes());
479        if !compressed.ends_with('\n') {
480            let _ = io::stdout().write_all(b"\n");
481        }
482    }
483    let should_tee = match cfg.tee_mode {
484        config::TeeMode::Always => !full_output.trim().is_empty(),
485        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
486        config::TeeMode::HighCompression => {
487            let orig = full_output.len();
488            let after = compressed.len();
489            let pct = if orig > 0 {
490                ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
491            } else {
492                0.0
493            };
494            pct > 70.0 && orig > 100
495        }
496        config::TeeMode::Never => false,
497    };
498    if should_tee {
499        if let Some(path) = super::redact::save_tee(command, &full_output) {
500            if !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
501                eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
502            }
503        }
504    }
505
506    let threshold = cfg.slow_command_threshold_ms;
507    if threshold > 0 && duration_ms >= threshold as u128 {
508        slow_log::record(command, duration_ms, exit_code);
509    }
510
511    #[cfg(windows)]
512    if let Some(ref tmp) = ps_tmp_path {
513        let _ = std::fs::remove_file(tmp);
514    }
515
516    exit_code
517}
518
519#[cfg(test)]
520mod exec_tests {
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 code = super::exec_argv(&["true".to_string()]);
559        assert_eq!(code, 0);
560    }
561
562    #[test]
563    fn exec_argv_passes_through_when_disabled() {
564        std::env::set_var("LEAN_CTX_DISABLED", "1");
565        let code = super::exec_argv(&["true".to_string()]);
566        std::env::remove_var("LEAN_CTX_DISABLED");
567        assert_eq!(code, 0);
568    }
569
570    #[test]
571    fn wait_with_limits_captures_output() {
572        let child = std::process::Command::new("echo")
573            .arg("hello")
574            .stdout(std::process::Stdio::piped())
575            .stderr(std::process::Stdio::piped())
576            .spawn()
577            .unwrap();
578
579        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
580        let stdout = String::from_utf8_lossy(&output.stdout);
581        assert!(
582            stdout.contains("hello"),
583            "expected 'hello' in output: {stdout}"
584        );
585        assert!(output.status.success());
586    }
587
588    #[test]
589    fn wait_with_limits_truncates_large_output() {
590        // Generate ~100 KB of output, limit to 1 KB
591        let child = std::process::Command::new("sh")
592            .args(["-c", "yes 'aaaa' | head -25000"])
593            .stdout(std::process::Stdio::piped())
594            .stderr(std::process::Stdio::piped())
595            .spawn()
596            .unwrap();
597
598        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
599        let stdout = String::from_utf8_lossy(&output.stdout);
600        assert!(
601            stdout.contains("[lean-ctx: output truncated"),
602            "expected truncation notice, got len={}: ...{}",
603            stdout.len(),
604            &stdout[stdout.len().saturating_sub(80)..]
605        );
606    }
607
608    #[test]
609    fn wait_with_limits_timeout_kills_process() {
610        let child = std::process::Command::new("sleep")
611            .arg("60")
612            .stdout(std::process::Stdio::piped())
613            .stderr(std::process::Stdio::piped())
614            .spawn()
615            .unwrap();
616
617        let start = std::time::Instant::now();
618        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
619        let elapsed = start.elapsed();
620
621        assert!(
622            elapsed < std::time::Duration::from_secs(3),
623            "timeout should kill quickly, took {elapsed:?}"
624        );
625        let stdout = String::from_utf8_lossy(&output.stdout);
626        assert!(stdout.contains("[lean-ctx: output truncated"));
627    }
628
629    #[test]
630    fn heavy_commands_get_higher_limits() {
631        let (bytes, timeout) = super::exec_limits("cargo build --release");
632        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
633        assert_eq!(timeout, super::HEAVY_TIMEOUT);
634
635        let (bytes, timeout) = super::exec_limits("cargo test --lib");
636        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
637        assert_eq!(timeout, super::HEAVY_TIMEOUT);
638
639        let (bytes, timeout) = super::exec_limits("npm run build");
640        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
641        assert_eq!(timeout, super::HEAVY_TIMEOUT);
642
643        let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
644        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
645        assert_eq!(timeout, super::HEAVY_TIMEOUT);
646    }
647
648    #[test]
649    fn normal_commands_get_default_limits() {
650        let (bytes, timeout) = super::exec_limits("echo hello");
651        assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
652        assert_eq!(timeout, super::DEFAULT_TIMEOUT);
653
654        let (bytes, timeout) = super::exec_limits("git status");
655        assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
656        assert_eq!(timeout, super::DEFAULT_TIMEOUT);
657    }
658
659    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
660    #[test]
661    fn allowlist_enforces_in_hook_child_mode() {
662        // Hook-child wins over everything, even an interactive TTY.
663        assert!(super::allowlist_must_enforce_inner(true, false, true));
664        assert!(super::allowlist_must_enforce_inner(true, true, true));
665    }
666
667    #[test]
668    fn allowlist_enforces_for_non_interactive_callers() {
669        // Agent/script invocation: stderr is a pipe → enforce.
670        assert!(super::allowlist_must_enforce_inner(false, false, false));
671    }
672
673    #[test]
674    fn allowlist_warns_for_interactive_humans() {
675        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
676        assert!(!super::allowlist_must_enforce_inner(false, false, true));
677    }
678
679    #[test]
680    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
681        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
682        assert!(!super::allowlist_must_enforce_inner(false, true, false));
683        assert!(super::allowlist_must_enforce_inner(true, true, false));
684    }
685}