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