use std::io::{self, IsTerminal, Read, Write};
use std::process::{Child, Command, Output, Stdio};
use crate::core::config;
use crate::core::slow_log;
use crate::core::tokens::count_tokens;
fn wait_with_limits(mut child: Child, max_bytes: usize, timeout: std::time::Duration) -> Output {
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let start = std::time::Instant::now();
let stdout_handle = std::thread::spawn(move || {
let Some(mut pipe) = stdout_pipe else {
return (Vec::new(), false);
};
let mut buf = Vec::with_capacity(max_bytes.min(64 * 1024));
let mut chunk = [0u8; 8192];
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if buf.len() + n > max_bytes {
let remaining = max_bytes.saturating_sub(buf.len());
buf.extend_from_slice(&chunk[..remaining]);
return (buf, true);
}
buf.extend_from_slice(&chunk[..n]);
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
(buf, false)
});
let stderr_handle = std::thread::spawn(move || {
let Some(mut pipe) = stderr_pipe else {
return Vec::new();
};
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
const STDERR_LIMIT: usize = 512 * 1024;
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if buf.len() + n > STDERR_LIMIT {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
buf
});
let mut timed_out = false;
loop {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
break;
}
match child.try_wait() {
Ok(Some(_)) | Err(_) => break,
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
}
}
let (mut stdout_buf, stdout_truncated) = stdout_handle.join().unwrap_or_default();
let stderr_buf = stderr_handle.join().unwrap_or_default();
if timed_out || stdout_truncated {
let notice = format!(
"\n[lean-ctx: output truncated at {} MB / {}s limit]\n",
max_bytes / (1024 * 1024),
timeout.as_secs()
);
stdout_buf.extend_from_slice(notice.as_bytes());
}
let status = child.wait().unwrap_or_else(|_| {
std::process::Command::new("false")
.status()
.expect("cannot run `false`")
});
Output {
status,
stdout: stdout_buf,
stderr: stderr_buf,
}
}
#[cfg(test)]
mod nested_lean_ctx_exec_tests {
#[test]
fn collapses_single_nested_c() {
assert_eq!(
super::collapse_nested_lean_ctx_exec("lean-ctx -c 'git status'").as_deref(),
Some("git status")
);
}
#[test]
fn collapses_repeated_nested_c() {
assert_eq!(
super::collapse_nested_lean_ctx_exec("lean-ctx -c 'lean-ctx -c \"git status\"'")
.as_deref(),
Some("git status")
);
}
#[test]
fn preserves_inner_shell_quoting() {
assert_eq!(
super::collapse_nested_lean_ctx_exec("lean-ctx -c \"git commit -m 'hello world'\"")
.as_deref(),
Some("git commit -m 'hello world'")
);
assert_eq!(
super::collapse_nested_lean_ctx_exec("lean-ctx -c git commit -m 'hello world'")
.as_deref(),
Some("git commit -m 'hello world'")
);
}
#[test]
fn collapses_exec_alias_and_path() {
assert_eq!(
super::collapse_nested_lean_ctx_exec("/usr/local/bin/lean-ctx exec 'git status'")
.as_deref(),
Some("git status")
);
}
#[test]
fn leaves_non_wrappers_alone() {
assert!(super::collapse_nested_lean_ctx_exec("git status").is_none());
}
#[test]
fn wrapped_nested_wrapper_still_owns_one_compression_pass() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var(super::super::reentry::WRAP_MARKER, "1");
assert!(super::should_delegate_wrapped_to_shell_default(false));
assert!(
!super::should_delegate_wrapped_to_shell_default(true),
"collapsed nested wrappers must not fall through to raw shell-default path"
);
crate::test_env::remove_var(super::super::reentry::WRAP_MARKER);
}
}
const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
fn exec_limits(command: &str) -> (usize, std::time::Duration) {
let max_bytes = if is_heavy_command(command) {
HEAVY_MAX_BYTES
} else {
DEFAULT_MAX_BYTES
};
(max_bytes, shell_timeout(command))
}
#[must_use]
pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
shell_timeout_with_override(command, None)
}
const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000;
#[must_use]
pub(crate) fn shell_timeout_with_override(
command: &str,
override_ms: Option<u64>,
) -> std::time::Duration {
if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
return std::time::Duration::from_millis(ms);
}
if let Some(ms) = override_ms.filter(|n| *n > 0) {
return std::time::Duration::from_millis(ms.min(MAX_CALL_TIMEOUT_MS));
}
if is_heavy_command(command) {
if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
.or_else(|| config::Config::load().shell_heavy_timeout_secs)
{
return std::time::Duration::from_secs(secs);
}
HEAVY_TIMEOUT
} else {
if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
.or_else(|| config::Config::load().shell_timeout_secs)
{
return std::time::Duration::from_secs(secs);
}
DEFAULT_TIMEOUT
}
}
fn env_u64(var: &str) -> Option<u64> {
std::env::var(var)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|n| *n > 0)
}
fn is_heavy_command(command: &str) -> bool {
let cmd = command.trim();
let lower = cmd.to_lowercase();
static HEAVY_PREFIXES: &[&str] = &[
"cargo build",
"cargo test",
"cargo nextest",
"cargo clippy",
"cargo check",
"cargo install",
"cargo bench",
"npm run build",
"npm install",
"npm ci",
"pnpm install",
"pnpm build",
"yarn install",
"yarn build",
"bun install",
"make",
"cmake",
"bazel build",
"bazel test",
"gradle build",
"gradle test",
"mvn package",
"mvn install",
"mvn test",
"go build",
"go test",
"dotnet build",
"dotnet test",
"swift build",
"swift test",
"flutter build",
"docker build",
"docker compose build",
"pip install",
"poetry install",
"uv sync",
"bundle install",
"mix compile",
"git commit",
"git push",
"mise ",
"just ",
];
HEAVY_PREFIXES.iter().any(|p| lower.starts_with(p))
}
pub fn exec_argv(args: &[String]) -> i32 {
if args.is_empty() {
return 127;
}
let joined = super::platform::join_command(args);
if let Some(u) = super::agent_wrapper::unwrap_agent_wrapper(&joined) {
return exec(&u.rebuild());
}
if let Some(code) = allowlist_gate(&joined) {
return code;
}
if super::reentry::should_pass_through() {
return exec_direct(args);
}
let cfg = config::Config::load();
let policy = 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 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::reentry::mark_child(&mut cmd);
super::platform::apply_utf8_locale(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => s.code().unwrap_or(1),
Err(e) => {
tracing::error!("lean-ctx: failed to execute: {e}");
127
}
}
}
fn allowlist_must_enforce() -> bool {
let hook_child = std::env::var("LEAN_CTX_HOOK_CHILD").is_ok();
let warn_only = std::env::var("LEAN_CTX_ALLOWLIST_WARN_ONLY")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
allowlist_must_enforce_inner(hook_child, warn_only, io::stderr().is_terminal())
}
fn allowlist_must_enforce_inner(hook_child: bool, warn_only: bool, stderr_is_tty: bool) -> bool {
if hook_child {
return true;
}
if warn_only {
return false;
}
!stderr_is_tty
}
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 allowlist_gate(command: &str) -> Option<i32> {
if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(command) {
if allowlist_must_enforce() {
eprintln!("{msg}");
eprintln!(
"lean-ctx: command blocked by shell allowlist. \
Allow it permanently: lean-ctx allow <cmd> — or set \
LEAN_CTX_ALLOWLIST_WARN_ONLY=1 to downgrade to a warning."
);
return Some(126);
}
if io::stderr().is_terminal() {
tracing::debug!("[CLI] Command would be blocked in MCP mode: {msg}");
} else {
tracing::warn!("[CLI] Command would be blocked in MCP mode: {msg}");
}
}
None
}
pub fn exec(command: &str) -> i32 {
let unwrapped = 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
};
if let Some(code) = allowlist_gate(command) {
return code;
}
let (shell, shell_flag) = super::platform::shell_and_flag();
let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
let command = command.as_str();
if 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 = std::env::var("LEAN_CTX_COMPRESS").is_ok();
let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
if raw_mode {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
let policy = super::output_policy::classify(command, &cfg.excluded_commands);
if policy == super::output_policy::OutputPolicy::Passthrough {
return exec_inherit_tracked(command, &shell, &shell_flag);
}
if policy == 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);
}
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::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)
}
fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
let mut cmd = Command::new(shell);
cmd.arg(shell_flag)
.arg(command)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
super::reentry::mark_child(&mut cmd);
super::platform::apply_utf8_locale(&mut cmd);
super::platform::apply_profile_free_env(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => s.code().unwrap_or(1),
Err(e) => {
tracing::error!("lean-ctx: failed to execute: {e}");
127
}
}
}
fn exec_shell_default(command: &str, shell: &str, shell_flag: &str) -> i32 {
let mut cmd = Command::new(shell);
cmd.arg(shell_flag)
.arg(command)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
super::reentry::clear_shell_default_markers(&mut cmd);
super::platform::apply_utf8_locale(&mut cmd);
super::platform::apply_profile_free_env(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => s.code().unwrap_or(1),
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(crate) const STDERR_LABEL: &str = "--- stderr ---";
pub(crate) 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}"),
}
}
fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
#[cfg(windows)]
super::platform::set_console_utf8();
let start = std::time::Instant::now();
let mut cmd = Command::new(shell);
#[cfg(windows)]
let ps_tmp_path: Option<tempfile::TempPath>;
#[cfg(windows)]
{
if super::platform::is_powershell(shell) {
let ps_script = format!(
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
command
);
match tempfile::Builder::new()
.prefix("lean-ctx-ps-")
.suffix(".ps1")
.tempfile()
{
Ok(tmp) => {
let tmp_path = tmp.into_temp_path();
let _ = std::fs::write(&tmp_path, &ps_script);
cmd.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
&tmp_path.to_string_lossy(),
]);
ps_tmp_path = Some(tmp_path);
}
Err(e) => {
tracing::warn!(
"lean-ctx: temp script unavailable ({e}); running PowerShell inline"
);
cmd.arg(shell_flag);
cmd.arg(command);
ps_tmp_path = None;
}
}
} else {
cmd.arg(shell_flag);
cmd.arg(command);
ps_tmp_path = None;
}
}
#[cfg(not(windows))]
{
cmd.arg(shell_flag);
cmd.arg(command);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
super::reentry::mark_child(&mut cmd);
super::platform::apply_utf8_locale(&mut cmd);
super::platform::apply_profile_free_env(&mut cmd);
let child = cmd.spawn();
let child = match child {
Ok(c) => c,
Err(e) => {
tracing::error!("lean-ctx: failed to execute: {e}");
#[cfg(windows)]
if let Some(ref tmp) = ps_tmp_path {
let _ = std::fs::remove_file(tmp);
}
return 127;
}
};
let (max_bytes, timeout) = exec_limits(command);
let output = wait_with_limits(child, max_bytes, timeout);
let duration_ms = start.elapsed().as_millis();
let exit_code = output.status.code().unwrap_or(1);
let stdout = super::platform::decode_output(&output.stdout);
let stderr = super::platform::decode_output(&output.stderr);
let full_output = combine_streams(&stdout, &stderr, exit_code);
let input_tokens = count_tokens(&full_output);
crate::core::diagnostics_store::record_from_shell(command, &full_output, exit_code);
crate::core::gotcha_tracker::record_shell_outcome(command, &full_output, exit_code);
let (compressed, output_tokens) =
super::compress::compress_and_measure(command, &stdout, &stderr, exit_code);
crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
if !compressed.is_empty() {
let _ = io::stdout().write_all(compressed.as_bytes());
if !compressed.ends_with('\n') {
let _ = io::stdout().write_all(b"\n");
}
}
let should_tee = super::tee_policy::should_tee(
&cfg.tee_mode,
exit_code,
full_output.trim().is_empty(),
input_tokens,
output_tokens,
);
if should_tee
&& let Some(path) = super::redact::save_tee(command, &full_output)
&& !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
{
eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
}
let threshold = cfg.slow_command_threshold_ms;
if threshold > 0 && duration_ms >= threshold as u128 {
slow_log::record(command, duration_ms, exit_code);
}
#[cfg(windows)]
if let Some(ref tmp) = ps_tmp_path {
let _ = std::fs::remove_file(tmp);
}
exit_code
}
#[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 exec_argv_runs_simple_command() {
let _lock = crate::core::data_dir::test_env_lock();
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::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::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(&["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, 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::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 wait_with_limits_captures_output() {
let child = std::process::Command::new("echo")
.arg("hello")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("hello"),
"expected 'hello' in output: {stdout}"
);
assert!(output.status.success());
}
#[test]
fn wait_with_limits_truncates_large_output() {
let child = std::process::Command::new("sh")
.args(["-c", "yes 'aaaa' | head -25000"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("[lean-ctx: output truncated"),
"expected truncation notice, got len={}: ...{}",
stdout.len(),
&stdout[stdout.len().saturating_sub(80)..]
);
}
#[test]
fn wait_with_limits_timeout_kills_process() {
let child = std::process::Command::new("sleep")
.arg("60")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let start = std::time::Instant::now();
let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(3),
"timeout should kill quickly, took {elapsed:?}"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("[lean-ctx: output truncated"));
}
#[test]
fn heavy_commands_get_higher_byte_limits() {
for cmd in [
"cargo build --release",
"cargo test --lib",
"cargo nextest run",
"npm run build",
"docker build -t myapp .",
"git commit --amend --no-edit",
"git push -u origin HEAD",
] {
let (bytes, _) = super::exec_limits(cmd);
assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
}
}
#[test]
fn normal_commands_get_default_byte_limits() {
for cmd in ["echo hello", "git status", "git log --oneline -5"] {
let (bytes, _) = super::exec_limits(cmd);
assert_eq!(
bytes,
super::DEFAULT_MAX_BYTES,
"default byte limit for {cmd}"
);
}
}
#[test]
fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
for v in [
"LEAN_CTX_SHELL_TIMEOUT_MS",
"LEAN_CTX_SHELL_TIMEOUT_SECS",
"LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
] {
crate::test_env::remove_var(v);
}
assert_eq!(
super::shell_timeout("cargo install --path ."),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("cargo nextest run"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("git commit -m 'wip'"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("git push origin main"),
super::HEAVY_TIMEOUT
);
assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
assert_eq!(
super::shell_timeout("cargo build"),
std::time::Duration::from_secs(90)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
assert_eq!(
super::shell_timeout("git status"),
std::time::Duration::from_secs(30)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
assert_eq!(
super::shell_timeout("cargo build"),
std::time::Duration::from_secs(5)
);
assert_eq!(
super::shell_timeout("git status"),
std::time::Duration::from_secs(5)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
for (var, saved) in [
("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
] {
if let Some(v) = saved {
crate::test_env::set_var(var, v);
}
}
}
#[test]
fn task_runners_get_heavy_ceiling() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
if let Some(v) = saved_ms {
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
}
if let Some(v) = saved_heavy {
crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
}
}
#[test]
fn per_call_timeout_override_resolves_and_clamps() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
assert_eq!(
super::shell_timeout_with_override("git status", Some(300_000)),
std::time::Duration::from_mins(5)
);
assert_eq!(
super::shell_timeout_with_override("cargo build", Some(30_000)),
std::time::Duration::from_secs(30)
);
assert_eq!(
super::shell_timeout_with_override("git status", Some(999_000_000)),
std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
);
assert_eq!(
super::shell_timeout_with_override("git status", Some(0)),
super::DEFAULT_TIMEOUT
);
assert_eq!(
super::shell_timeout_with_override("git status", None),
super::DEFAULT_TIMEOUT
);
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
assert_eq!(
super::shell_timeout_with_override("git status", Some(300_000)),
std::time::Duration::from_secs(5)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
if let Some(v) = saved_ms {
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
}
}
#[test]
fn allowlist_enforces_in_hook_child_mode() {
assert!(super::allowlist_must_enforce_inner(true, false, true));
assert!(super::allowlist_must_enforce_inner(true, true, true));
}
#[test]
fn allowlist_enforces_for_non_interactive_callers() {
assert!(super::allowlist_must_enforce_inner(false, false, false));
}
#[test]
fn allowlist_warns_for_interactive_humans() {
assert!(!super::allowlist_must_enforce_inner(false, false, true));
}
#[test]
fn allowlist_warn_only_opt_out_downgrades_non_interactive() {
assert!(!super::allowlist_must_enforce_inner(false, true, false));
assert!(super::allowlist_must_enforce_inner(true, true, false));
}
}