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    // Gotcha learning: a failing build/test pushes a pending error; the next
486    // green run of the same command base correlates the fix into a gotcha.
487    crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
488
489    let (compressed, output_tokens) =
490        super::compress::compress_and_measure(command, &stdout, &stderr);
491
492    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
493
494    if !compressed.is_empty() {
495        let _ = io::stdout().write_all(compressed.as_bytes());
496        if !compressed.ends_with('\n') {
497            let _ = io::stdout().write_all(b"\n");
498        }
499    }
500    let should_tee = match cfg.tee_mode {
501        config::TeeMode::Always => !full_output.trim().is_empty(),
502        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
503        config::TeeMode::HighCompression => {
504            let orig = full_output.len();
505            let after = compressed.len();
506            let pct = if orig > 0 {
507                ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
508            } else {
509                0.0
510            };
511            pct > 70.0 && orig > 100
512        }
513        config::TeeMode::Never => false,
514    };
515    if should_tee
516        && let Some(path) = super::redact::save_tee(command, &full_output)
517        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
518    {
519        eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
520    }
521
522    let threshold = cfg.slow_command_threshold_ms;
523    if threshold > 0 && duration_ms >= threshold as u128 {
524        slow_log::record(command, duration_ms, exit_code);
525    }
526
527    #[cfg(windows)]
528    if let Some(ref tmp) = ps_tmp_path {
529        let _ = std::fs::remove_file(tmp);
530    }
531
532    exit_code
533}
534
535#[cfg(test)]
536mod exec_tests {
537    #[test]
538    fn exec_direct_runs_true() {
539        let code = super::exec_direct(&["true".to_string()]);
540        assert_eq!(code, 0);
541    }
542
543    #[test]
544    fn exec_direct_runs_false() {
545        let code = super::exec_direct(&["false".to_string()]);
546        assert_ne!(code, 0);
547    }
548
549    #[test]
550    fn exec_direct_preserves_args_with_special_chars() {
551        let code = super::exec_direct(&[
552            "echo".to_string(),
553            "hello world".to_string(),
554            "it's here".to_string(),
555            "a \"quoted\" thing".to_string(),
556        ]);
557        assert_eq!(code, 0);
558    }
559
560    #[test]
561    fn exec_direct_nonexistent_returns_127() {
562        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
563        assert_eq!(code, 127);
564    }
565
566    #[test]
567    fn exec_argv_empty_returns_127() {
568        let code = super::exec_argv(&[]);
569        assert_eq!(code, 127);
570    }
571
572    #[test]
573    fn exec_argv_runs_simple_command() {
574        let code = super::exec_argv(&["true".to_string()]);
575        assert_eq!(code, 0);
576    }
577
578    #[test]
579    fn exec_argv_passes_through_when_disabled() {
580        crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
581        let code = super::exec_argv(&["true".to_string()]);
582        crate::test_env::remove_var("LEAN_CTX_DISABLED");
583        assert_eq!(code, 0);
584    }
585
586    #[test]
587    fn wait_with_limits_captures_output() {
588        let child = std::process::Command::new("echo")
589            .arg("hello")
590            .stdout(std::process::Stdio::piped())
591            .stderr(std::process::Stdio::piped())
592            .spawn()
593            .unwrap();
594
595        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
596        let stdout = String::from_utf8_lossy(&output.stdout);
597        assert!(
598            stdout.contains("hello"),
599            "expected 'hello' in output: {stdout}"
600        );
601        assert!(output.status.success());
602    }
603
604    #[test]
605    fn wait_with_limits_truncates_large_output() {
606        // Generate ~100 KB of output, limit to 1 KB
607        let child = std::process::Command::new("sh")
608            .args(["-c", "yes 'aaaa' | head -25000"])
609            .stdout(std::process::Stdio::piped())
610            .stderr(std::process::Stdio::piped())
611            .spawn()
612            .unwrap();
613
614        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
615        let stdout = String::from_utf8_lossy(&output.stdout);
616        assert!(
617            stdout.contains("[lean-ctx: output truncated"),
618            "expected truncation notice, got len={}: ...{}",
619            stdout.len(),
620            &stdout[stdout.len().saturating_sub(80)..]
621        );
622    }
623
624    #[test]
625    fn wait_with_limits_timeout_kills_process() {
626        let child = std::process::Command::new("sleep")
627            .arg("60")
628            .stdout(std::process::Stdio::piped())
629            .stderr(std::process::Stdio::piped())
630            .spawn()
631            .unwrap();
632
633        let start = std::time::Instant::now();
634        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
635        let elapsed = start.elapsed();
636
637        assert!(
638            elapsed < std::time::Duration::from_secs(3),
639            "timeout should kill quickly, took {elapsed:?}"
640        );
641        let stdout = String::from_utf8_lossy(&output.stdout);
642        assert!(stdout.contains("[lean-ctx: output truncated"));
643    }
644
645    #[test]
646    fn heavy_commands_get_higher_limits() {
647        let (bytes, timeout) = super::exec_limits("cargo build --release");
648        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
649        assert_eq!(timeout, super::HEAVY_TIMEOUT);
650
651        let (bytes, timeout) = super::exec_limits("cargo test --lib");
652        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
653        assert_eq!(timeout, super::HEAVY_TIMEOUT);
654
655        let (bytes, timeout) = super::exec_limits("cargo nextest run");
656        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
657        assert_eq!(timeout, super::HEAVY_TIMEOUT);
658
659        let (bytes, timeout) = super::exec_limits("npm run build");
660        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
661        assert_eq!(timeout, super::HEAVY_TIMEOUT);
662
663        let (bytes, timeout) = super::exec_limits("docker build -t myapp .");
664        assert_eq!(bytes, super::HEAVY_MAX_BYTES);
665        assert_eq!(timeout, super::HEAVY_TIMEOUT);
666    }
667
668    #[test]
669    fn normal_commands_get_default_limits() {
670        let (bytes, timeout) = super::exec_limits("echo hello");
671        assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
672        assert_eq!(timeout, super::DEFAULT_TIMEOUT);
673
674        let (bytes, timeout) = super::exec_limits("git status");
675        assert_eq!(bytes, super::DEFAULT_MAX_BYTES);
676        assert_eq!(timeout, super::DEFAULT_TIMEOUT);
677    }
678
679    #[test]
680    fn heavy_timeout_some_for_heavy_none_otherwise() {
681        assert_eq!(
682            super::heavy_timeout("cargo install --path ."),
683            Some(super::HEAVY_TIMEOUT)
684        );
685        assert_eq!(
686            super::heavy_timeout("cargo nextest run"),
687            Some(super::HEAVY_TIMEOUT)
688        );
689        assert_eq!(super::heavy_timeout("git status"), None);
690        assert_eq!(super::heavy_timeout("ls -la"), None);
691    }
692
693    // P0-1 (#413): the CLI allowlist must enforce for agents, warn for humans.
694    #[test]
695    fn allowlist_enforces_in_hook_child_mode() {
696        // Hook-child wins over everything, even an interactive TTY.
697        assert!(super::allowlist_must_enforce_inner(true, false, true));
698        assert!(super::allowlist_must_enforce_inner(true, true, true));
699    }
700
701    #[test]
702    fn allowlist_enforces_for_non_interactive_callers() {
703        // Agent/script invocation: stderr is a pipe → enforce.
704        assert!(super::allowlist_must_enforce_inner(false, false, false));
705    }
706
707    #[test]
708    fn allowlist_warns_for_interactive_humans() {
709        // Human at a TTY → warn-only (they can bypass lean-ctx anyway).
710        assert!(!super::allowlist_must_enforce_inner(false, false, true));
711    }
712
713    #[test]
714    fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
715        // Explicit LEAN_CTX_ALLOWLIST_WARN_ONLY=1 opt-out (but never in hook-child mode).
716        assert!(!super::allowlist_must_enforce_inner(false, true, false));
717        assert!(super::allowlist_must_enforce_inner(true, true, false));
718    }
719}