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 = super::super::platform::zsh_safe_command(&command, &shell);
254 let command = command.as_str();
255
256 if super::super::reentry::is_disabled() {
257 return exec_inherit(command, &shell, &shell_flag);
258 }
259 if should_delegate_wrapped_to_shell_default(collapsed_nested) {
260 return exec_shell_default(command, &shell, &shell_flag);
261 }
262
263 let cfg = config::Config::load();
264 let force_compress = crate::core::runtime_flags::compress_enabled();
265 let raw_mode = crate::core::runtime_flags::raw_enabled();
266
267 if raw_mode {
268 return exec_inherit_tracked(command, &shell, &shell_flag);
269 }
270
271 let policy = super::super::output_policy::classify(command, &cfg.excluded_commands);
272
273 if policy == super::super::output_policy::OutputPolicy::Passthrough {
275 return exec_inherit_tracked(command, &shell, &shell_flag);
276 }
277
278 if policy == super::super::output_policy::OutputPolicy::Verbatim && !force_compress {
282 return exec_inherit_tracked(command, &shell, &shell_flag);
283 }
284
285 if !force_compress {
286 if io::stdout().is_terminal() {
287 return exec_inherit_tracked(command, &shell, &shell_flag);
288 }
289 let code = exec_inherit(command, &shell, &shell_flag);
290 crate::core::tool_lifecycle::record_shell_command(0, 0);
291 return code;
292 }
293
294 if stdout_is_regular_file() {
303 return exec_inherit_tracked(command, &shell, &shell_flag);
304 }
305
306 if command_has_file_redirect(command) {
313 return exec_inherit_tracked(command, &shell, &shell_flag);
314 }
315
316 super::super::pipeline::exec_buffered(command, &shell, &shell_flag, &cfg)
317}
318
319fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
320 let mut current = command.trim().to_string();
321 let mut changed = false;
322
323 while let Some(next) = strip_one_lean_ctx_exec(¤t) {
324 if next == current {
325 break;
326 }
327 current = next;
328 changed = true;
329 }
330
331 changed.then_some(current)
332}
333
334fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
335 super::super::reentry::is_wrapped() && !collapsed_nested
339}
340
341fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
342 let words = split_simple_shell_words(command)?;
343 if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
344 return None;
345 }
346 if words[1].value != "-c" && words[1].value != "exec" {
347 return None;
348 }
349 if words[2..].iter().any(|w| {
350 matches!(
351 w.value.as_str(),
352 "|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
353 )
354 }) {
355 return None;
356 }
357 if words.len() == 3 {
358 Some(words[2].value.trim().to_string())
359 } else {
360 Some(command[words[2].start..].trim().to_string())
361 }
362}
363
364fn is_lean_ctx_bin(word: &str) -> bool {
365 std::path::Path::new(word)
366 .file_name()
367 .and_then(|name| name.to_str())
368 .is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
369}
370
371struct SimpleShellWord {
372 value: String,
373 start: usize,
374}
375
376fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
377 let mut words = Vec::new();
378 let mut current = String::new();
379 let mut current_start: Option<usize> = None;
380 let mut chars = command.char_indices().peekable();
381 let mut quote: Option<char> = None;
382
383 while let Some((idx, ch)) = chars.next() {
384 match quote {
385 Some('\'') if ch == '\'' => quote = None,
386 Some('"') if ch == '"' => quote = None,
387 None if ch == '\'' || ch == '"' => {
388 current_start.get_or_insert(idx);
389 quote = Some(ch);
390 }
391 Some('"') | None if ch == '\\' => {
392 current_start.get_or_insert(idx);
393 if let Some((_, next)) = chars.next() {
394 current.push(next);
395 }
396 }
397 None if ch.is_whitespace() => {
398 if let Some(start) = current_start.take() {
399 words.push(SimpleShellWord {
400 value: std::mem::take(&mut current),
401 start,
402 });
403 }
404 }
405 Some(_) | None => {
406 current_start.get_or_insert(idx);
407 current.push(ch);
408 }
409 }
410 }
411
412 if quote.is_some() {
413 return None;
414 }
415 if let Some(start) = current_start {
416 words.push(SimpleShellWord {
417 value: current,
418 start,
419 });
420 }
421 (!words.is_empty()).then_some(words)
422}
423
424fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
425 let mut cmd = Command::new(shell);
426 cmd.arg(shell_flag)
427 .arg(command)
428 .stdin(Stdio::inherit())
429 .stdout(Stdio::inherit())
430 .stderr(Stdio::inherit());
431 super::super::reentry::mark_child(&mut cmd);
432 super::super::platform::apply_utf8_locale(&mut cmd);
433 super::super::platform::apply_profile_free_env(&mut cmd);
434 let status = cmd.status();
435
436 match status {
437 Ok(s) => s.code().unwrap_or(1),
438 Err(e) => {
439 tracing::error!("lean-ctx: failed to execute: {e}");
440 127
441 }
442 }
443}
444
445fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
446 let mut cmd = Command::new(shell);
447 cmd.arg(shell_flag)
448 .arg(command)
449 .stdin(Stdio::inherit())
450 .stdout(Stdio::inherit())
451 .stderr(Stdio::inherit());
452 super::super::reentry::clear_shell_default_markers(&mut cmd);
453 super::super::platform::apply_utf8_locale(&mut cmd);
454 super::super::platform::apply_profile_free_env(&mut cmd);
455 let status = cmd.status();
456
457 match status {
458 Ok(s) => s.code().unwrap_or(1),
459 Err(e) => {
460 eprintln!("lean-ctx: failed to execute '{command}': {e}");
461 127
462 }
463 }
464}
465
466fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
467 let code = exec_inherit(command, shell, shell_flag);
468 crate::core::tool_lifecycle::record_shell_command(0, 0);
469 code
470}
471
472pub(crate) const STDERR_LABEL: &str = "--- stderr ---";
476
477pub(crate) fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
481 match (stdout.is_empty(), stderr.is_empty()) {
482 (_, true) => stdout.to_string(),
483 (true, false) => stderr.to_string(),
484 (false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
485 (false, false) => format!("{stdout}\n{stderr}"),
486 }
487}
488
489#[cfg(test)]
492mod nested_lean_ctx_exec_tests;
493
494#[cfg(test)]
495mod exec_tests {
496 #[test]
497 fn combine_streams_labels_stderr_on_failure() {
498 let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
499 assert_eq!(
500 out,
501 format!(
502 "build ok\n{}\nlinker: undefined symbol",
503 super::STDERR_LABEL
504 )
505 );
506 }
507
508 #[test]
509 fn combine_streams_plain_join_on_success() {
510 let out = super::combine_streams("step 1", "warning: noop", 0);
511 assert_eq!(out, "step 1\nwarning: noop");
512 assert!(!out.contains(super::STDERR_LABEL));
513 }
514
515 #[test]
516 fn combine_streams_single_stream_is_unchanged() {
517 assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
518 assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
519 }
520
521 #[test]
522 fn exec_direct_runs_true() {
523 let code = super::exec_direct(&["true".to_string()]);
524 assert_eq!(code, 0);
525 }
526
527 #[test]
528 fn exec_direct_runs_false() {
529 let code = super::exec_direct(&["false".to_string()]);
530 assert_ne!(code, 0);
531 }
532
533 #[test]
534 fn exec_direct_preserves_args_with_special_chars() {
535 let code = super::exec_direct(&[
536 "echo".to_string(),
537 "hello world".to_string(),
538 "it's here".to_string(),
539 "a \"quoted\" thing".to_string(),
540 ]);
541 assert_eq!(code, 0);
542 }
543
544 #[test]
545 fn exec_direct_nonexistent_returns_127() {
546 let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
547 assert_eq!(code, 127);
548 }
549
550 #[test]
551 fn exec_argv_empty_returns_127() {
552 let code = super::exec_argv(&[]);
553 assert_eq!(code, 127);
554 }
555
556 #[test]
557 fn exec_argv_runs_simple_command() {
558 let _lock = crate::core::data_dir::test_env_lock();
559 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
560 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
561 let code = super::exec_argv(&["true".to_string()]);
562 assert_eq!(code, 0);
563 }
564
565 #[test]
566 fn exec_argv_passes_through_when_disabled() {
567 let _lock = crate::core::data_dir::test_env_lock();
568 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
569 crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
570 let code = super::exec_argv(&["true".to_string()]);
571 crate::test_env::remove_var("LEAN_CTX_DISABLED");
572 assert_eq!(code, 0);
573 }
574
575 #[test]
579 fn exec_argv_enforces_allowlist_for_disallowed_command() {
580 let _lock = crate::core::data_dir::test_env_lock();
581 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
582 crate::test_env::remove_var("LEAN_CTX_DISABLED");
583 crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
584 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
586 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
587
588 let code = super::exec_argv(&["xxd".to_string()]);
591
592 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
593 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
594
595 assert_eq!(
596 code, 126,
597 "non-allowlisted command must be blocked on the -t track path"
598 );
599 }
600
601 #[test]
602 fn exec_argv_allows_allowlisted_command() {
603 let _lock = crate::core::data_dir::test_env_lock();
604 crate::test_env::remove_var("LEAN_CTX_ACTIVE");
605 crate::test_env::remove_var("LEAN_CTX_DISABLED");
606 crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
607 crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
608
609 let code = super::exec_argv(&["true".to_string()]);
610
611 crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
612 crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
613
614 assert_eq!(code, 0, "allowlisted command must run on the -t track path");
615 }
616 #[test]
618 fn allowlist_enforces_in_hook_child_mode() {
619 assert!(super::allowlist_must_enforce_inner(true, false, true));
621 assert!(super::allowlist_must_enforce_inner(true, true, true));
622 }
623
624 #[test]
625 fn allowlist_enforces_for_non_interactive_callers() {
626 assert!(super::allowlist_must_enforce_inner(false, false, false));
628 }
629
630 #[test]
631 fn allowlist_warns_for_interactive_humans() {
632 assert!(!super::allowlist_must_enforce_inner(false, false, true));
634 }
635
636 #[test]
637 fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
638 assert!(!super::allowlist_must_enforce_inner(false, true, false));
640 assert!(super::allowlist_must_enforce_inner(true, true, false));
641 }
642
643 #[test]
646 fn redirect_to_file_detected() {
647 assert!(super::command_has_file_redirect("git show HEAD:f > out.md"));
648 assert!(super::command_has_file_redirect("git diff >> changes.log"));
649 assert!(super::command_has_file_redirect(
650 "git status > /tmp/status.txt"
651 ));
652 }
653
654 #[test]
655 fn no_redirect_not_detected() {
656 assert!(!super::command_has_file_redirect("git status"));
657 assert!(!super::command_has_file_redirect("cargo test --lib"));
658 }
659
660 #[test]
661 fn dev_null_not_detected_as_redirect() {
662 assert!(!super::command_has_file_redirect("cargo test > /dev/null"));
663 assert!(!super::command_has_file_redirect("cmd > /dev/stdout"));
664 assert!(!super::command_has_file_redirect("cmd > /dev/stderr"));
665 }
666
667 #[test]
668 fn stderr_redirect_not_detected() {
669 assert!(!super::command_has_file_redirect(
670 "cargo test 2> errors.log"
671 ));
672 assert!(!super::command_has_file_redirect("cargo test 2>/dev/null"));
673 }
674
675 #[test]
676 fn fd_dup_not_detected() {
677 assert!(!super::command_has_file_redirect("cargo test 2>&1"));
678 assert!(!super::command_has_file_redirect("cmd >&2"));
679 }
680
681 #[test]
682 fn quoted_redirect_not_detected() {
683 assert!(!super::command_has_file_redirect("echo 'a > b'"));
684 assert!(!super::command_has_file_redirect("echo \"a > b\""));
685 assert!(!super::command_has_file_redirect(
686 "gh pr create --body 'see > details'"
687 ));
688 }
689
690 #[test]
691 fn escaped_redirect_not_detected() {
692 assert!(!super::command_has_file_redirect("echo a \\> b"));
693 }
694}