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
8fn 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; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
105const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
107
108fn exec_limits(command: &str) -> (usize, std::time::Duration) {
109 let max_bytes = if is_heavy_command(command) {
110 HEAVY_MAX_BYTES
111 } else {
112 DEFAULT_MAX_BYTES
113 };
114 (max_bytes, shell_timeout(command))
115}
116
117#[must_use]
130pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
131 if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
132 return std::time::Duration::from_millis(ms);
133 }
134 if is_heavy_command(command) {
135 if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
136 .or_else(|| config::Config::load().shell_heavy_timeout_secs)
137 {
138 return std::time::Duration::from_secs(secs);
139 }
140 HEAVY_TIMEOUT
141 } else {
142 if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
143 .or_else(|| config::Config::load().shell_timeout_secs)
144 {
145 return std::time::Duration::from_secs(secs);
146 }
147 DEFAULT_TIMEOUT
148 }
149}
150
151fn env_u64(var: &str) -> Option<u64> {
154 std::env::var(var)
155 .ok()
156 .and_then(|v| v.parse::<u64>().ok())
157 .filter(|n| *n > 0)
158}
159
160fn is_heavy_command(command: &str) -> bool {
161 let cmd = command.trim();
162 let lower = cmd.to_lowercase();
163 static HEAVY_PREFIXES: &[&str] = &[
164 "cargo build",
165 "cargo test",
166 "cargo nextest",
167 "cargo clippy",
168 "cargo check",
169 "cargo install",
170 "cargo bench",
171 "npm run build",
172 "npm install",
173 "npm ci",
174 "pnpm install",
175 "pnpm build",
176 "yarn install",
177 "yarn build",
178 "bun install",
179 "make",
180 "cmake",
181 "bazel build",
182 "bazel test",
183 "gradle build",
184 "gradle test",
185 "mvn package",
186 "mvn install",
187 "mvn test",
188 "go build",
189 "go test",
190 "dotnet build",
191 "dotnet test",
192 "swift build",
193 "swift test",
194 "flutter build",
195 "docker build",
196 "docker compose build",
197 "pip install",
198 "poetry install",
199 "uv sync",
200 "bundle install",
201 "mix compile",
202 "git commit",
209 "git push",
210 ];
211 HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
212}
213
214pub fn exec_argv(args: &[String]) -> i32 {
220 if args.is_empty() {
221 return 127;
222 }
223
224 let joined = super::platform::join_command(args);
229
230 if let Some(code) = allowlist_gate(&joined) {
236 return code;
237 }
238
239 if super::reentry::should_pass_through() {
240 return exec_direct(args);
241 }
242
243 let cfg = config::Config::load();
244 let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
245
246 if policy.is_protected() {
247 let code = exec_direct(args);
248 crate::core::tool_lifecycle::record_shell_command(0, 0);
249 return code;
250 }
251
252 let code = exec_direct(args);
253 crate::core::tool_lifecycle::record_shell_command(0, 0);
254 code
255}
256
257fn exec_direct(args: &[String]) -> i32 {
258 let mut cmd = Command::new(&args[0]);
259 cmd.args(&args[1..])
260 .stdin(Stdio::inherit())
261 .stdout(Stdio::inherit())
262 .stderr(Stdio::inherit());
263 super::reentry::mark_child(&mut cmd);
264 super::platform::apply_utf8_locale(&mut cmd);
265 let status = cmd.status();
266
267 match status {
268 Ok(s) => s.code().unwrap_or(1),
269 Err(e) => {
270 tracing::error!("lean-ctx: failed to execute: {e}");
271 127
272 }
273 }
274}
275
276fn allowlist_must_enforce() -> bool {
289 let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
290 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
291 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
292 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
293}
294
295fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
298 if hook_child {
299 return true;
300 }
301 if warn_only {
302 return false;
303 }
304 !stderr_is_tty
305}
306
307fn stdout_is_regular_file() -> bool {
320 #[cfg(unix)]
321 {
322 use std::os::unix::io::{AsRawFd, FromRawFd};
323 let fd = io::stdout().as_raw_fd();
324 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
327 file.metadata().is_ok_and(|m| m.is_file())
328 }
329 #[cfg(windows)]
330 {
331 use std::os::windows::io::{AsRawHandle, FromRawHandle};
332 let handle = io::stdout().as_raw_handle();
333 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
336 file.metadata().is_ok_and(|m| m.is_file())
337 }
338 #[cfg(not(any(unix, windows)))]
339 {
340 false
341 }
342}
343
344fn allowlist_gate(command: &str) -> Option<i32> {
352 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
353 if allowlist_must_enforce() {
354 eprintln!("{msg}");
355 eprintln!(
356 "lean-ctx: command blocked by shell allowlist. \
357 Allow it permanently: lean-ctx allow <cmd> — or set \
358 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
359 );
360 return Some(126);
361 }
362 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
363 }
364 None
365}
366
367pub fn exec(command: &str) -> i32 {
368 if let Some(code) = allowlist_gate(command) {
369 return code;
370 }
371
372 let (shell, shell_flag) = super::platform::shell_and_flag();
373 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
374 let command = command.as_str();
375
376 if super::reentry::should_pass_through() {
377 return exec_inherit(command, &shell, &shell_flag);
378 }
379
380 let cfg = config::Config::load();
381 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
382 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
383
384 if raw_mode {
385 return exec_inherit_tracked(command, &shell, &shell_flag);
386 }
387
388 let policy = super::output_policy::classify(command, &cfg.excluded_commands);
389
390 if policy == super::output_policy::OutputPolicy::Passthrough {
392 return exec_inherit_tracked(command, &shell, &shell_flag);
393 }
394
395 if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
399 return exec_inherit_tracked(command, &shell, &shell_flag);
400 }
401
402 if !force_compress {
403 if io::stdout().is_terminal() {
404 return exec_inherit_tracked(command, &shell, &shell_flag);
405 }
406 let code = exec_inherit(command, &shell, &shell_flag);
407 crate::core::tool_lifecycle::record_shell_command(0, 0);
408 return code;
409 }
410
411 if stdout_is_regular_file() {
420 return exec_inherit_tracked(command, &shell, &shell_flag);
421 }
422
423 exec_buffered(command, &shell, &shell_flag, &cfg)
424}
425
426fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
427 let mut cmd = Command::new(shell);
428 cmd.arg(shell_flag)
429 .arg(command)
430 .stdin(Stdio::inherit())
431 .stdout(Stdio::inherit())
432 .stderr(Stdio::inherit());
433 super::reentry::mark_child(&mut cmd);
434 super::platform::apply_utf8_locale(&mut cmd);
435 super::platform::apply_profile_free_env(&mut cmd);
436 let status = cmd.status();
437
438 match status {
439 Ok(s) => s.code().unwrap_or(1),
440 Err(e) => {
441 tracing::error!("lean-ctx: failed to execute: {e}");
442 127
443 }
444 }
445}
446
447fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
448 let code = exec_inherit(command, shell, shell_flag);
449 crate::core::tool_lifecycle::record_shell_command(0, 0);
450 code
451}
452
453pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
457
458pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
462 match (stdout.is_empty(), stderr.is_empty()) {
463 (_, true) => stdout.to_string(),
464 (true, false) => stderr.to_string(),
465 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
466 (false, false) => format!("{stdout}\n{stderr}"),
467 }
468}
469
470fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
471 #[cfg(windows)]
472 super::platform::set_console_utf8();
473
474 let start = std::time::Instant::now();
475
476 let mut cmd = Command::new(shell);
477
478 #[cfg(windows)]
479 let ps_tmp_path: Option<tempfile::TempPath>;
480 #[cfg(windows)]
481 {
482 if super::platform::is_powershell(shell) {
483 let ps_script = format!(
484 "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
485 command
486 );
487 match tempfile::Builder::new()
491 .prefix("lean-ctx-ps-")
492 .suffix(".ps1")
493 .tempfile()
494 {
495 Ok(tmp) => {
496 let tmp_path = tmp.into_temp_path();
497 let _ = std::fs::write(&tmp_path, &ps_script);
498 cmd.args([
499 "-NoProfile",
500 "-ExecutionPolicy",
501 "Bypass",
502 "-File",
503 &tmp_path.to_string_lossy(),
504 ]);
505 ps_tmp_path = Some(tmp_path);
506 }
507 Err(e) => {
508 tracing::warn!(
509 "lean-ctx: temp script unavailable ({e}); running PowerShell inline"
510 );
511 cmd.arg(shell_flag);
512 cmd.arg(command);
513 ps_tmp_path = None;
514 }
515 }
516 } else {
517 cmd.arg(shell_flag);
518 cmd.arg(command);
519 ps_tmp_path = None;
520 }
521 }
522 #[cfg(not(windows))]
523 {
524 cmd.arg(shell_flag);
525 cmd.arg(command);
526 }
527
528 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
529 super::reentry::mark_child(&mut cmd);
530 super::platform::apply_utf8_locale(&mut cmd);
531 super::platform::apply_profile_free_env(&mut cmd);
532 let child = cmd.spawn();
533
534 let child = match child {
535 Ok(c) => c,
536 Err(e) => {
537 tracing::error!("lean-ctx: failed to execute: {e}");
538 #[cfg(windows)]
539 if let Some(ref tmp) = ps_tmp_path {
540 let _ = std::fs::remove_file(tmp);
541 }
542 return 127;
543 }
544 };
545
546 let (max_bytes, timeout) = exec_limits(command);
547 let output = wait_with_limits(child, max_bytes, timeout);
548
549 let duration_ms = start.elapsed().as_millis();
550 let exit_code = output.status.code().unwrap_or(1);
551 let stdout = super::platform::decode_output(&output.stdout);
552 let stderr = super::platform::decode_output(&output.stderr);
553
554 let full_output = combine_streams(&stdout, &stderr, exit_code);
555 let input_tokens = count_tokens(&full_output);
556
557 crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
560
561 crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
564
565 let (compressed, output_tokens) =
566 super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
567
568 crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
569
570 if !compressed.is_empty() {
571 let _ = io::stdout().write_all(compressed.as_bytes());
572 if !compressed.ends_with('\n') {
573 let _ = io::stdout().write_all(b"\n");
574 }
575 }
576 let should_tee = super::tee_policy::should_tee(
579 &cfg.tee_mode,
580 exit_code,
581 full_output.trim().is_empty(),
582 input_tokens,
583 output_tokens,
584 );
585 if should_tee
586 && let Some(path) = super::redact::save_tee(command, &full_output)
587 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
588 {
589 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
590 }
591
592 let threshold = cfg.slow_command_threshold_ms;
593 if threshold > 0 && duration_ms >= threshold as u128 {
594 slow_log::record(command, duration_ms, exit_code);
595 }
596
597 #[cfg(windows)]
598 if let Some(ref tmp) = ps_tmp_path {
599 let _ = std::fs::remove_file(tmp);
600 }
601
602 exit_code
603}
604
605#[cfg(test)]
606mod exec_tests {
607 #[test]
608 fn combine_streams_labels_stderr_on_failure() {
609 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
610 assert_eq!(
611 out,
612 format!(
613 "build ok\n{}\nlinker: undefined symbol",
614 super::STDERR_LABEL
615 )
616 );
617 }
618
619 #[test]
620 fn combine_streams_plain_join_on_success() {
621 let out = super::combine_streams("step 1", "warning: noop", 0);
622 assert_eq!(out, "step 1\nwarning: noop");
623 assert!(!out.contains(super::STDERR_LABEL));
624 }
625
626 #[test]
627 fn combine_streams_single_stream_is_unchanged() {
628 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
629 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
630 }
631
632 #[test]
633 fn exec_direct_runs_true() {
634 let code = super::exec_direct(&["true".to_string()]);
635 assert_eq!(code, 0);
636 }
637
638 #[test]
639 fn exec_direct_runs_false() {
640 let code = super::exec_direct(&["false".to_string()]);
641 assert_ne!(code, 0);
642 }
643
644 #[test]
645 fn exec_direct_preserves_args_with_special_chars() {
646 let code = super::exec_direct(&[
647 "echo".to_string(),
648 "hello world".to_string(),
649 "it's here".to_string(),
650 "a \"quoted\" thing".to_string(),
651 ]);
652 assert_eq!(code, 0);
653 }
654
655 #[test]
656 fn exec_direct_nonexistent_returns_127() {
657 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
658 assert_eq!(code, 127);
659 }
660
661 #[test]
662 fn exec_argv_empty_returns_127() {
663 let code = super::exec_argv(&[]);
664 assert_eq!(code, 127);
665 }
666
667 #[test]
668 fn exec_argv_runs_simple_command() {
669 let _lock = crate::core::data_dir::test_env_lock();
670 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
671 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
672 let code = super::exec_argv(&["true".to_string()]);
673 assert_eq!(code, 0);
674 }
675
676 #[test]
677 fn exec_argv_passes_through_when_disabled() {
678 let _lock = crate::core::data_dir::test_env_lock();
679 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
680 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
681 let code = super::exec_argv(&["true".to_string()]);
682 crate::test_env::remove_var("LEAN_CTX_DISABLED");
683 assert_eq!(code, 0);
684 }
685
686 #[test]
690 fn exec_argv_enforces_allowlist_for_disallowed_command() {
691 let _lock = crate::core::data_dir::test_env_lock();
692 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
693 crate::test_env::remove_var("LEAN_CTX_DISABLED");
694 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
695 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
697 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
698
699 let code = super::exec_argv(&["true".to_string()]);
700
701 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
702 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
703
704 assert_eq!(
705 code, 126,
706 "non-allowlisted command must be blocked on the -t track path"
707 );
708 }
709
710 #[test]
711 fn exec_argv_allows_allowlisted_command() {
712 let _lock = crate::core::data_dir::test_env_lock();
713 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
714 crate::test_env::remove_var("LEAN_CTX_DISABLED");
715 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
716 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
717
718 let code = super::exec_argv(&["true".to_string()]);
719
720 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
721 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
722
723 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
724 }
725
726 #[test]
727 fn wait_with_limits_captures_output() {
728 let child = std::process::Command::new("echo")
729 .arg("hello")
730 .stdout(std::process::Stdio::piped())
731 .stderr(std::process::Stdio::piped())
732 .spawn()
733 .unwrap();
734
735 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
736 let stdout = String::from_utf8_lossy(&output.stdout);
737 assert!(
738 stdout.contains("hello"),
739 "expected 'hello' in output: {stdout}"
740 );
741 assert!(output.status.success());
742 }
743
744 #[test]
745 fn wait_with_limits_truncates_large_output() {
746 let child = std::process::Command::new("sh")
748 .args(["-c", "yes 'aaaa' | head -25000"])
749 .stdout(std::process::Stdio::piped())
750 .stderr(std::process::Stdio::piped())
751 .spawn()
752 .unwrap();
753
754 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
755 let stdout = String::from_utf8_lossy(&output.stdout);
756 assert!(
757 stdout.contains("[lean-ctx: output truncated"),
758 "expected truncation notice, got len={}: ...{}",
759 stdout.len(),
760 &stdout[stdout.len().saturating_sub(80)..]
761 );
762 }
763
764 #[test]
765 fn wait_with_limits_timeout_kills_process() {
766 let child = std::process::Command::new("sleep")
767 .arg("60")
768 .stdout(std::process::Stdio::piped())
769 .stderr(std::process::Stdio::piped())
770 .spawn()
771 .unwrap();
772
773 let start = std::time::Instant::now();
774 let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
775 let elapsed = start.elapsed();
776
777 assert!(
778 elapsed < std::time::Duration::from_secs(3),
779 "timeout should kill quickly, took {elapsed:?}"
780 );
781 let stdout = String::from_utf8_lossy(&output.stdout);
782 assert!(stdout.contains("[lean-ctx: output truncated"));
783 }
784
785 #[test]
786 fn heavy_commands_get_higher_byte_limits() {
787 for cmd in [
792 "cargo build --release",
793 "cargo test --lib",
794 "cargo nextest run",
795 "npm run build",
796 "docker build -t myapp .",
797 "git commit --amend --no-edit",
800 "git push -u origin HEAD",
801 ] {
802 let (bytes, _) = super::exec_limits(cmd);
803 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
804 }
805 }
806
807 #[test]
808 fn normal_commands_get_default_byte_limits() {
809 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
812 let (bytes, _) = super::exec_limits(cmd);
813 assert_eq!(
814 bytes,
815 super::DEFAULT_MAX_BYTES,
816 "default byte limit for {cmd}"
817 );
818 }
819 }
820
821 #[test]
822 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
823 let _lock = crate::core::data_dir::test_env_lock();
825 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
826 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
827 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
828 for v in [
829 "LEAN_CTX_SHELL_TIMEOUT_MS",
830 "LEAN_CTX_SHELL_TIMEOUT_SECS",
831 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
832 ] {
833 crate::test_env::remove_var(v);
834 }
835
836 assert_eq!(
839 super::shell_timeout("cargo install --path ."),
840 super::HEAVY_TIMEOUT
841 );
842 assert_eq!(
843 super::shell_timeout("cargo nextest run"),
844 super::HEAVY_TIMEOUT
845 );
846 assert_eq!(
847 super::shell_timeout("git commit -m 'wip'"),
848 super::HEAVY_TIMEOUT
849 );
850 assert_eq!(
851 super::shell_timeout("git push origin main"),
852 super::HEAVY_TIMEOUT
853 );
854 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
855 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
856
857 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
860 assert_eq!(
861 super::shell_timeout("cargo build"),
862 std::time::Duration::from_secs(90)
863 );
864 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
865
866 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
867 assert_eq!(
868 super::shell_timeout("git status"),
869 std::time::Duration::from_secs(30)
870 );
871 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
872
873 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
875 assert_eq!(
876 super::shell_timeout("cargo build"),
877 std::time::Duration::from_secs(5)
878 );
879 assert_eq!(
880 super::shell_timeout("git status"),
881 std::time::Duration::from_secs(5)
882 );
883 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
884
885 for (var, saved) in [
886 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
887 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
888 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
889 ] {
890 if let Some(v) = saved {
891 crate::test_env::set_var(var, v);
892 }
893 }
894 }
895
896 #[test]
898 fn allowlist_enforces_in_hook_child_mode() {
899 assert!(super::allowlist_must_enforce_inner(true, false, true));
901 assert!(super::allowlist_must_enforce_inner(true, true, true));
902 }
903
904 #[test]
905 fn allowlist_enforces_for_non_interactive_callers() {
906 assert!(super::allowlist_must_enforce_inner(false, false, false));
908 }
909
910 #[test]
911 fn allowlist_warns_for_interactive_humans() {
912 assert!(!super::allowlist_must_enforce_inner(false, false, true));
914 }
915
916 #[test]
917 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
918 assert!(!super::allowlist_must_enforce_inner(false, true, false));
920 assert!(super::allowlist_must_enforce_inner(true, true, false));
921 }
922}