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    let status = cmd.status();
366
367    match status {
368        Ok(s) => s.code().unwrap_or(1),
369        Err(e) => {
370            tracing::error!("lean-ctx: failed to execute: {e}");
371            127
372        }
373    }
374}
375
376fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
377    let code = exec_inherit(command, shell, shell_flag);
378    crate::core::tool_lifecycle::record_shell_command(0, 0);
379    code
380}
381
382fn combine_output(stdout: &str, stderr: &str) -> String {
383    if stderr.is_empty() {
384        stdout.to_string()
385    } else if stdout.is_empty() {
386        stderr.to_string()
387    } else {
388        format!("{stdout}\n{stderr}")
389    }
390}
391
392fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
393    #[cfg(windows)]
394    super::platform::set_console_utf8();
395
396    let start = std::time::Instant::now();
397
398    let mut cmd = Command::new(shell);
399
400    #[cfg(windows)]
401    let ps_tmp_path: Option<tempfile::TempPath>;
402    #[cfg(windows)]
403    {
404        if super::platform::is_powershell(shell) {
405            let ps_script = format!(
406                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
407                command
408            );
409            // A temp script lets us set UTF-8 output encoding. If the temp file
410            // cannot be created (full disk, perms, broken TMP), degrade to
411            // running the command inline rather than panicking the process.
412            match tempfile::Builder::new()
413                .prefix("lean-ctx-ps-")
414                .suffix(".ps1")
415                .tempfile()
416            {
417                Ok(tmp) => {
418                    let tmp_path = tmp.into_temp_path();
419                    let _ = std::fs::write(&tmp_path, &ps_script);
420                    cmd.args([
421                        "-NoProfile",
422                        "-ExecutionPolicy",
423                        "Bypass",
424                        "-File",
425                        &tmp_path.to_string_lossy(),
426                    ]);
427                    ps_tmp_path = Some(tmp_path);
428                }
429                Err(e) => {
430                    tracing::warn!(
431                        "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
432                    );
433                    cmd.arg(shell_flag);
434                    cmd.arg(command);
435                    ps_tmp_path = None;
436                }
437            }
438        } else {
439            cmd.arg(shell_flag);
440            cmd.arg(command);
441            ps_tmp_path = None;
442        }
443    }
444    #[cfg(not(windows))]
445    {
446        cmd.arg(shell_flag);
447        cmd.arg(command);
448    }
449
450    cmd.env("LEAN_CTX_ACTIVE", "1")
451        .stdout(Stdio::piped())
452        .stderr(Stdio::piped());
453    super::platform::apply_utf8_locale(&mut cmd);
454    let child = cmd.spawn();
455
456    let child = match child {
457        Ok(c) => c,
458        Err(e) => {
459            tracing::error!("lean-ctx: failed to execute: {e}");
460            #[cfg(windows)]
461            if let Some(ref tmp) = ps_tmp_path {
462                let _ = std::fs::remove_file(tmp);
463            }
464            return 127;
465        }
466    };
467
468    let (max_bytes, timeout) = exec_limits(command);
469    let output = wait_with_limits(child, max_bytes, timeout);
470
471    let duration_ms = start.elapsed().as_millis();
472    let exit_code = output.status.code().unwrap_or(1);
473    let stdout = super::platform::decode_output(&output.stdout);
474    let stderr = super::platform::decode_output(&output.stderr);
475
476    let full_output = combine_output(&stdout, &stderr);
477    let input_tokens = count_tokens(&full_output);
478
479    // Structured diagnostics (#499): failing cargo/tsc/eslint runs mark their
480    // files as context-priority; succeeding runs clear them.
481    crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
482
483    let (compressed, output_tokens) =
484        super::compress::compress_and_measure(command, &stdout, &stderr);
485
486    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
487
488    if !compressed.is_empty() {
489        let _ = io::stdout().write_all(compressed.as_bytes());
490        if !compressed.ends_with('\n') {
491            let _ = io::stdout().write_all(b"\n");
492        }
493    }
494    let should_tee = match cfg.tee_mode {
495        config::TeeMode::Always => !full_output.trim().is_empty(),
496        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
497        config::TeeMode::HighCompression => {
498            let orig = full_output.len();
499            let after = compressed.len();
500            let pct = if orig > 0 {
501                ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
502            } else {
503                0.0
504            };
505            pct > 70.0 && orig > 100
506        }
507        config::TeeMode::Never => false,
508    };
509    if should_tee
510        && let Some(path) = super::redact::save_tee(command, &full_output)
511        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
512    {
513        eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
514    }
515
516    let threshold = cfg.slow_command_threshold_ms;
517    if threshold > 0 && duration_ms >= threshold as u128 {
518        slow_log::record(command, duration_ms, exit_code);
519    }
520
521    #[cfg(windows)]
522    if let Some(ref tmp) = ps_tmp_path {
523        let _ = std::fs::remove_file(tmp);
524    }
525
526    exit_code
527}
528
529#[cfg(test)]
530mod exec_tests {
531    #[test]
532    fn exec_direct_runs_true() {
533        let code = super::exec_direct(&["true".to_string()]);
534        assert_eq!(code, 0);
535    }
536
537    #[test]
538    fn exec_direct_runs_false() {
539        let code = super::exec_direct(&["false".to_string()]);
540        assert_ne!(code, 0);
541    }
542
543    #[test]
544    fn exec_direct_preserves_args_with_special_chars() {
545        let code = super::exec_direct(&[
546            "echo".to_string(),
547            "hello world".to_string(),
548            "it's here".to_string(),
549            "a \"quoted\" thing".to_string(),
550        ]);
551        assert_eq!(code, 0);
552    }
553
554    #[test]
555    fn exec_direct_nonexistent_returns_127() {
556        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
557        assert_eq!(code, 127);
558    }
559
560    #[test]
561    fn exec_argv_empty_returns_127() {
562        let code = super::exec_argv(&[]);
563        assert_eq!(code, 127);
564    }
565
566    #[test]
567    fn exec_argv_runs_simple_command() {
568        let code = super::exec_argv(&["true".to_string()]);
569        assert_eq!(code, 0);
570    }
571
572    #[test]
573    fn exec_argv_passes_through_when_disabled() {
574        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
575        let code = super::exec_argv(&["true".to_string()]);
576        crate::test_env::remove_var("LEAN_CTX_DISABLED");
577        assert_eq!(code, 0);
578    }
579
580    #[test]
581    fn wait_with_limits_captures_output() {
582        let child = std::process::Command::new("echo")
583            .arg("hello")
584            .stdout(std::process::Stdio::piped())
585            .stderr(std::process::Stdio::piped())
586            .spawn()
587            .unwrap();
588
589        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
590        let stdout = String::from_utf8_lossy(&output.stdout);
591        assert!(
592            stdout.contains("hello"),
593            "expected 'hello' in output: {stdout}"
594        );
595        assert!(output.status.success());
596    }
597
598    #[test]
599    fn wait_with_limits_truncates_large_output() {
600        // Generate ~100 KB of output, limit to 1 KB
601        let child = std::process::Command::new("sh")
602            .args(["-c", "yes 'aaaa' | head -25000"])
603            .stdout(std::process::Stdio::piped())
604            .stderr(std::process::Stdio::piped())
605            .spawn()
606            .unwrap();
607
608        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
609        let stdout = String::from_utf8_lossy(&output.stdout);
610        assert!(
611            stdout.contains("[lean-ctx: output truncated"),
612            "expected truncation notice, got len={}: ...{}",
613            stdout.len(),
614            &stdout[stdout.len().saturating_sub(80)..]
615        );
616    }
617
618    #[test]
619    fn wait_with_limits_timeout_kills_process() {
620        let child = std::process::Command::new("sleep")
621            .arg("60")
622            .stdout(std::process::Stdio::piped())
623            .stderr(std::process::Stdio::piped())
624            .spawn()
625            .unwrap();
626
627        let start = std::time::Instant::now();
628        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
629        let elapsed = start.elapsed();
630
631        assert!(
632            elapsed < std::time::Duration::from_secs(3),
633            "timeout should kill quickly, took {elapsed:?}"
634        );
635        let stdout = String::from_utf8_lossy(&output.stdout);
636        assert!(stdout.contains("[lean-ctx: output truncated"));
637    }
638
639    #[test]
640    fn heavy_commands_get_higher_limits() {
641        let (bytes, timeout) = super::exec_limits("cargo build --release");
642        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
643        assert_eq!(timeout, super::HEAVY_TIMEOUT);
644
645        let (bytes, timeout) = super::exec_limits("cargo test --lib");
646        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
647        assert_eq!(timeout, super::HEAVY_TIMEOUT);
648
649        let (bytes, timeout) = super::exec_limits("cargo nextest run");
650        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
651        assert_eq!(timeout, super::HEAVY_TIMEOUT);
652
653        let (bytes, timeout) = super::exec_limits("npm run build");
654        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
655        assert_eq!(timeout, super::HEAVY_TIMEOUT);
656
657        let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
658        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
659        assert_eq!(timeout, super::HEAVY_TIMEOUT);
660    }
661
662    #[test]
663    fn normal_commands_get_default_limits() {
664        let (bytes, timeout) = super::exec_limits("echo hello");
665        assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
666        assert_eq!(timeout, super::DEFAULT_TIMEOUT);
667
668        let (bytes, timeout) = super::exec_limits("git status");
669        assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
670        assert_eq!(timeout, super::DEFAULT_TIMEOUT);
671    }
672
673    #[test]
674    fn heavy_timeout_some_for_heavy_none_otherwise() {
675        assert_eq!(
676            super::heavy_timeout("cargo install --path ."),
677            Some(super::HEAVY_TIMEOUT)
678        );
679        assert_eq!(
680            super::heavy_timeout("cargo nextest run"),
681            Some(super::HEAVY_TIMEOUT)
682        );
683        assert_eq!(super::heavy_timeout("git status"), None);
684        assert_eq!(super::heavy_timeout("ls -la"), None);
685    }
686
687    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
688    #[test]
689    fn allowlist_enforces_in_hook_child_mode() {
690        // Hook-child wins over everything, even an interactive TTY.
691        assert!(super::allowlist_must_enforce_inner(true, false, true));
692        assert!(super::allowlist_must_enforce_inner(true, true, true));
693    }
694
695    #[test]
696    fn allowlist_enforces_for_non_interactive_callers() {
697        // Agent/script invocation: stderr is a pipe → enforce.
698        assert!(super::allowlist_must_enforce_inner(false, false, false));
699    }
700
701    #[test]
702    fn allowlist_warns_for_interactive_humans() {
703        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
704        assert!(!super::allowlist_must_enforce_inner(false, false, true));
705    }
706
707    #[test]
708    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
709        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
710        assert!(!super::allowlist_must_enforce_inner(false, true, false));
711        assert!(super::allowlist_must_enforce_inner(true, true, false));
712    }
713}