1use std::io::{self, IsTerminal};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5
6pub fn exec_argv(args: &[String]) -> i32 {
12 if args.is_empty() {
13 return 127;
14 }
15
16 let joined = super::super::platform::join_command(args);
21
22 if let Some(u) = super::super::agent_wrapper::unwrap_agent_wrapper(&joined) {
26 return exec(&u.rebuild());
27 }
28
29 if let Some(code) = allowlist_gate(&joined) {
35 return code;
36 }
37
38 if super::super::reentry::should_pass_through() {
39 return exec_direct(args);
40 }
41
42 let cfg = config::Config::load();
43 let policy = super::super::output_policy::classify(&joined, &cfg.excluded_commands);
44
45 if policy.is_protected() {
46 let code = exec_direct(args);
47 crate::core::tool_lifecycle::record_shell_command(0, 0);
48 return code;
49 }
50
51 let code = exec_direct(args);
52 crate::core::tool_lifecycle::record_shell_command(0, 0);
53 code
54}
55
56fn exec_direct(args: &[String]) -> i32 {
57 let mut cmd = Command::new(&args[0]);
58 cmd.args(&args[1..])
59 .stdin(Stdio::inherit())
60 .stdout(Stdio::inherit())
61 .stderr(Stdio::inherit());
62 super::super::reentry::mark_child(&mut cmd);
63 super::super::platform::apply_utf8_locale(&mut cmd);
64 let status = cmd.status();
65
66 match status {
67 Ok(s) => s.code().unwrap_or(1),
68 Err(e) => {
69 tracing::error!("lean-ctx: failed to execute: {e}");
70 127
71 }
72 }
73}
74
75fn allowlist_must_enforce() -> bool {
88 let hook_child = crate::core::runtime_flags::hook_child_enabled();
89 let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
90 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
91 allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
92}
93
94fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
97 if hook_child {
98 return true;
99 }
100 if warn_only {
101 return false;
102 }
103 !stderr_is_tty
104}
105
106fn stdout_is_regular_file() -> bool {
119 #[cfg(unix)]
120 {
121 use std::os::unix::io::{AsRawFd, FromRawFd};
122 let fd = io::stdout().as_raw_fd();
123 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
126 file.metadata().is_ok_and(|m| m.is_file())
127 }
128 #[cfg(windows)]
129 {
130 use std::os::windows::io::{AsRawHandle, FromRawHandle};
131 let handle = io::stdout().as_raw_handle();
132 let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
135 file.metadata().is_ok_and(|m| m.is_file())
136 }
137 #[cfg(not(any(unix, windows)))]
138 {
139 false
140 }
141}
142
143fn command_has_file_redirect(cmd: &str) -> bool {
147 let bytes = cmd.as_bytes();
148 let len = bytes.len();
149 let mut i = 0;
150 let mut in_single_quote = false;
151 let mut in_double_quote = false;
152
153 while i < len {
154 let c = bytes[i];
155 if c == b'\\' && !in_single_quote {
156 i += 2;
157 continue;
158 }
159 if c == b'\'' && !in_double_quote {
160 in_single_quote = !in_single_quote;
161 } else if c == b'"' && !in_single_quote {
162 in_double_quote = !in_double_quote;
163 } else if c == b'>' && !in_single_quote && !in_double_quote {
164 if i > 0 && bytes[i - 1] == b'2' {
165 i += 1;
166 continue;
167 }
168 let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
169 i + 2
170 } else {
171 i + 1
172 };
173 let target: String = cmd[target_start..]
174 .trim_start()
175 .chars()
176 .take_while(|c| !c.is_whitespace())
177 .collect();
178 if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
179 i += 1;
180 continue;
181 }
182 if let Some(fd) = target.strip_prefix('&')
183 && !fd.is_empty()
184 && (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
185 {
186 i += 1;
187 continue;
188 }
189 if !target.is_empty() {
190 return true;
191 }
192 }
193 i += 1;
194 }
195 false
196}
197
198fn allowlist_gate(command: &str) -> Option<i32> {
206 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
207 if allowlist_must_enforce() {
208 eprintln!("{msg}");
209 eprintln!(
210 "lean-ctx: command blocked by shell allowlist. \
211 Allow it permanently: lean-ctx allow <cmd> — or set \
212 LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
213 );
214 return Some(126);
215 }
216 if io::stderr().is_terminal() {
221 tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
222 } else {
223 tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
224 }
225 }
226 None
227}
228
229pub fn exec(command: &str) -> i32 {
230 let unwrapped = super::super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
236 let mut collapsed_nested = false;
237 let collapsed;
238 let command = unwrapped.as_deref().unwrap_or(command);
239 let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
240 collapsed_nested = true;
241 collapsed = c;
242 collapsed.as_str()
243 } else {
244 command
245 };
246
247 if let Some(code) = allowlist_gate(command) {
248 return code;
249 }
250
251 let (shell, shell_flag) = super::super::platform::shell_and_flag();
252 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
253 let command = command.as_str();
254
255 if super::super::reentry::is_disabled() {
256 return exec_inherit(command, &shell, &shell_flag);
257 }
258 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
259 return exec_shell_default(command, &shell, &shell_flag);
260 }
261
262 let cfg = config::Config::load();
263 let force_compress = crate::core::runtime_flags::compress_enabled();
264 let raw_mode = crate::core::runtime_flags::raw_enabled();
265
266 if raw_mode {
267 return exec_inherit_tracked(command, &shell, &shell_flag);
268 }
269
270 let policy = super::super::output_policy::classify(command, &cfg.excluded_commands);
271
272 if policy == super::super::output_policy::OutputPolicy::Passthrough {
274 return exec_inherit_tracked(command, &shell, &shell_flag);
275 }
276
277 if policy == super::super::output_policy::OutputPolicy::Verbatim && !force_compress {
281 return exec_inherit_tracked(command, &shell, &shell_flag);
282 }
283
284 if !force_compress {
285 if io::stdout().is_terminal() {
286 return exec_inherit_tracked(command, &shell, &shell_flag);
287 }
288 let code = exec_inherit(command, &shell, &shell_flag);
289 crate::core::tool_lifecycle::record_shell_command(0, 0);
290 return code;
291 }
292
293 if stdout_is_regular_file() {
302 return exec_inherit_tracked(command, &shell, &shell_flag);
303 }
304
305 if command_has_file_redirect(command) {
312 return exec_inherit_tracked(command, &shell, &shell_flag);
313 }
314
315 super::super::pipeline::exec_buffered(command, &shell, &shell_flag, &cfg)
316}
317
318fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
319 let mut current = command.trim().to_string();
320 let mut changed = false;
321
322 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
323 if next == current {
324 break;
325 }
326 current = next;
327 changed = true;
328 }
329
330 changed.then_some(current)
331}
332
333fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
334 super::super::reentry::is_wrapped() && !collapsed_nested
338}
339
340fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
341 let words = split_simple_shell_words(command)?;
342 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
343 return None;
344 }
345 if words[1].value != "-c" && words[1].value != "exec" {
346 return None;
347 }
348 if words[2..].iter().any(|w| {
349 matches!(
350 w.value.as_str(),
351 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
352 )
353 }) {
354 return None;
355 }
356 if words.len() == 3 {
357 Some(words[2].value.trim().to_string())
358 } else {
359 Some(command[words[2].start..].trim().to_string())
360 }
361}
362
363fn is_lean_ctx_bin(word: &str) -> bool {
364 std::path::Path::new(word)
365 .file_name()
366 .and_then(|name| name.to_str())
367 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
368}
369
370struct SimpleShellWord {
371 value: String,
372 start: usize,
373}
374
375fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
376 let mut words = Vec::new();
377 let mut current = String::new();
378 let mut current_start: Option<usize> = None;
379 let mut chars = command.char_indices().peekable();
380 let mut quote: Option<char> = None;
381
382 while let Some((idx, ch)) = chars.next() {
383 match quote {
384 Some('\'') if ch == '\'' => quote = None,
385 Some('"') if ch == '"' => quote = None,
386 None if ch == '\'' || ch == '"' => {
387 current_start.get_or_insert(idx);
388 quote = Some(ch);
389 }
390 Some('"') | None if ch == '\\' => {
391 current_start.get_or_insert(idx);
392 if let Some((_, next)) = chars.next() {
393 current.push(next);
394 }
395 }
396 None if ch.is_whitespace() => {
397 if let Some(start) = current_start.take() {
398 words.push(SimpleShellWord {
399 value: std::mem::take(&mut current),
400 start,
401 });
402 }
403 }
404 Some(_) | None => {
405 current_start.get_or_insert(idx);
406 current.push(ch);
407 }
408 }
409 }
410
411 if quote.is_some() {
412 return None;
413 }
414 if let Some(start) = current_start {
415 words.push(SimpleShellWord {
416 value: current,
417 start,
418 });
419 }
420 (!words.is_empty()).then_some(words)
421}
422
423fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
424 let mut cmd = Command::new(shell);
425 cmd.arg(shell_flag)
426 .arg(command)
427 .stdin(Stdio::inherit())
428 .stdout(Stdio::inherit())
429 .stderr(Stdio::inherit());
430 super::super::reentry::mark_child(&mut cmd);
431 super::super::platform::apply_utf8_locale(&mut cmd);
432 super::super::platform::apply_profile_free_env(&mut cmd);
433 let status = cmd.status();
434
435 match status {
436 Ok(s) => s.code().unwrap_or(1),
437 Err(e) => {
438 tracing::error!("lean-ctx: failed to execute: {e}");
439 127
440 }
441 }
442}
443
444fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
445 let mut cmd = Command::new(shell);
446 cmd.arg(shell_flag)
447 .arg(command)
448 .stdin(Stdio::inherit())
449 .stdout(Stdio::inherit())
450 .stderr(Stdio::inherit());
451 super::super::reentry::clear_shell_default_markers(&mut cmd);
452 super::super::platform::apply_utf8_locale(&mut cmd);
453 super::super::platform::apply_profile_free_env(&mut cmd);
454 let status = cmd.status();
455
456 match status {
457 Ok(s) => s.code().unwrap_or(1),
458 Err(e) => {
459 eprintln!("lean-ctx: failed to execute '{command}': {e}");
460 127
461 }
462 }
463}
464
465fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
466 let code = exec_inherit(command, shell, shell_flag);
467 crate::core::tool_lifecycle::record_shell_command(0, 0);
468 code
469}
470
471pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
475
476pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
480 match (stdout.is_empty(), stderr.is_empty()) {
481 (_, true) => stdout.to_string(),
482 (true, false) => stderr.to_string(),
483 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
484 (false, false) => format!("{stdout}\n{stderr}"),
485 }
486}
487
488#[cfg(test)]
491mod nested_lean_ctx_exec_tests;
492
493#[cfg(test)]
494mod exec_tests {
495 #[test]
496 fn combine_streams_labels_stderr_on_failure() {
497 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
498 assert_eq!(
499 out,
500 format!(
501 "build ok\n{}\nlinker: undefined symbol",
502 super::STDERR_LABEL
503 )
504 );
505 }
506
507 #[test]
508 fn combine_streams_plain_join_on_success() {
509 let out = super::combine_streams("step 1", "warning: noop", 0);
510 assert_eq!(out, "step 1\nwarning: noop");
511 assert!(!out.contains(super::STDERR_LABEL));
512 }
513
514 #[test]
515 fn combine_streams_single_stream_is_unchanged() {
516 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
517 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
518 }
519
520 #[test]
521 fn exec_direct_runs_true() {
522 let code = super::exec_direct(&["true".to_string()]);
523 assert_eq!(code, 0);
524 }
525
526 #[test]
527 fn exec_direct_runs_false() {
528 let code = super::exec_direct(&["false".to_string()]);
529 assert_ne!(code, 0);
530 }
531
532 #[test]
533 fn exec_direct_preserves_args_with_special_chars() {
534 let code = super::exec_direct(&[
535 "echo".to_string(),
536 "hello world".to_string(),
537 "it's here".to_string(),
538 "a \"quoted\" thing".to_string(),
539 ]);
540 assert_eq!(code, 0);
541 }
542
543 #[test]
544 fn exec_direct_nonexistent_returns_127() {
545 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
546 assert_eq!(code, 127);
547 }
548
549 #[test]
550 fn exec_argv_empty_returns_127() {
551 let code = super::exec_argv(&[]);
552 assert_eq!(code, 127);
553 }
554
555 #[test]
556 fn exec_argv_runs_simple_command() {
557 let _lock = crate::core::data_dir::test_env_lock();
558 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
559 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
560 let code = super::exec_argv(&["true".to_string()]);
561 assert_eq!(code, 0);
562 }
563
564 #[test]
565 fn exec_argv_passes_through_when_disabled() {
566 let _lock = crate::core::data_dir::test_env_lock();
567 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
568 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
569 let code = super::exec_argv(&["true".to_string()]);
570 crate::test_env::remove_var("LEAN_CTX_DISABLED");
571 assert_eq!(code, 0);
572 }
573
574 #[test]
578 fn exec_argv_enforces_allowlist_for_disallowed_command() {
579 let _lock = crate::core::data_dir::test_env_lock();
580 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
581 crate::test_env::remove_var("LEAN_CTX_DISABLED");
582 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
583 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
585 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
586
587 let code = super::exec_argv(&["xxd".to_string()]);
590
591 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
592 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
593
594 assert_eq!(
595 code, 126,
596 "non-allowlisted command must be blocked on the -t track path"
597 );
598 }
599
600 #[test]
601 fn exec_argv_allows_allowlisted_command() {
602 let _lock = crate::core::data_dir::test_env_lock();
603 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
604 crate::test_env::remove_var("LEAN_CTX_DISABLED");
605 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
606 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
607
608 let code = super::exec_argv(&["true".to_string()]);
609
610 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
611 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
612
613 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
614 }
615 #[test]
617 fn allowlist_enforces_in_hook_child_mode() {
618 assert!(super::allowlist_must_enforce_inner(true, false, true));
620 assert!(super::allowlist_must_enforce_inner(true, true, true));
621 }
622
623 #[test]
624 fn allowlist_enforces_for_non_interactive_callers() {
625 assert!(super::allowlist_must_enforce_inner(false, false, false));
627 }
628
629 #[test]
630 fn allowlist_warns_for_interactive_humans() {
631 assert!(!super::allowlist_must_enforce_inner(false, false, true));
633 }
634
635 #[test]
636 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
637 assert!(!super::allowlist_must_enforce_inner(false, true, false));
639 assert!(super::allowlist_must_enforce_inner(true, true, false));
640 }
641
642 #[test]
645 fn redirect_to_file_detected() {
646 assert!(super::command_has_file_redirect("git show HEAD:f > out.md"));
647 assert!(super::command_has_file_redirect("git diff >> changes.log"));
648 assert!(super::command_has_file_redirect(
649 "git status > /tmp/status.txt"
650 ));
651 }
652
653 #[test]
654 fn no_redirect_not_detected() {
655 assert!(!super::command_has_file_redirect("git status"));
656 assert!(!super::command_has_file_redirect("cargo test --lib"));
657 }
658
659 #[test]
660 fn dev_null_not_detected_as_redirect() {
661 assert!(!super::command_has_file_redirect("cargo test > /dev/null"));
662 assert!(!super::command_has_file_redirect("cmd > /dev/stdout"));
663 assert!(!super::command_has_file_redirect("cmd > /dev/stderr"));
664 }
665
666 #[test]
667 fn stderr_redirect_not_detected() {
668 assert!(!super::command_has_file_redirect(
669 "cargo test 2> errors.log"
670 ));
671 assert!(!super::command_has_file_redirect("cargo test 2>/dev/null"));
672 }
673
674 #[test]
675 fn fd_dup_not_detected() {
676 assert!(!super::command_has_file_redirect("cargo test 2>&1"));
677 assert!(!super::command_has_file_redirect("cmd >&2"));
678 }
679
680 #[test]
681 fn quoted_redirect_not_detected() {
682 assert!(!super::command_has_file_redirect("echo 'a > b'"));
683 assert!(!super::command_has_file_redirect("echo \"a > b\""));
684 assert!(!super::command_has_file_redirect(
685 "gh pr create --body 'see > details'"
686 ));
687 }
688
689 #[test]
690 fn escaped_redirect_not_detected() {
691 assert!(!super::command_has_file_redirect("echo a \\> b"));
692 }
693}