use super::search_rewrite::{rewrite_dir_list_command, rewrite_search_command};
use super::{
HOOK_STDIN_TIMEOUT, build_dual_allow_output, build_dual_rewrite_output, dedup, is_disabled,
is_shell_tool, payload, read_stdin_with_timeout, resolve_binary, shell_quote, shell_tokenize,
};
use crate::compound_lexer;
use crate::core::debug_log::{self, Route};
use crate::rewrite_registry;
pub(super) fn compute_rewrite() -> String {
if is_disabled() {
return build_dual_allow_output();
}
let binary = resolve_binary();
let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
return build_dual_allow_output();
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
return build_dual_allow_output();
};
let Some(tool_name) = payload::resolve_tool_name(&v) else {
return build_dual_allow_output();
};
if !is_shell_tool(&tool_name) {
return build_dual_allow_output();
}
let tool_args = payload::resolve_tool_args(&v);
let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
return build_dual_allow_output();
};
let key_material = format!("{tool_name}\u{0}{cmd}");
dedup::deduped("rewrite", &key_material, || {
if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
debug_log::log_hook_decision(
"rewrite",
&tool_name,
Route::LeanCtx,
&cmd,
"rewritable command",
);
build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
} else {
debug_log::log_hook_decision(
"rewrite",
&tool_name,
Route::Native,
&cmd,
rewrite_skip_reason(&cmd),
);
build_dual_allow_output()
}
})
}
pub(super) fn rewrite_skip_reason(cmd: &str) -> &'static str {
if cmd.starts_with("lean-ctx ") {
"already a lean-ctx command"
} else if cmd.contains("<<") {
"heredoc cannot be rewritten safely"
} else if is_compound(cmd) && !crate::core::shell_allowlist::passes_enforced(cmd) {
"compound pipes/chains into a non-allowlisted or interpreter sink — left raw for the agent shell"
} else {
"not a known read/search/list command"
}
}
pub(super) fn is_rewritable(cmd: &str) -> bool {
rewrite_registry::is_rewritable_command(cmd)
}
fn is_compound(cmd: &str) -> bool {
compound_lexer::split_compound(cmd)
.iter()
.any(|s| matches!(s, compound_lexer::Segment::Operator(_)))
}
pub(super) fn wrap_single_command(cmd: &str, binary: &str) -> String {
if cfg!(windows) {
let escaped = cmd.replace('"', "\\\"");
format!("{binary} -c \"{escaped}\"")
} else {
let shell_escaped = cmd.replace('\'', "'\\''");
format!("{binary} -c '{shell_escaped}'")
}
}
fn has_stdout_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(super) fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
return None;
}
if cmd.contains("<<") {
return None;
}
{
let stripped = crate::rewrite_registry::strip_env_prefix(cmd);
if stripped.len() != cmd.len() {
let prefix_part = &cmd[..cmd.len() - stripped.len()];
if prefix_part.contains("LEAN_CTX_DISABLED") || prefix_part.contains("LEAN_CTX_NO_HOOK")
{
return None;
}
}
}
if has_stdout_file_redirect(cmd) {
return None;
}
if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
return Some(rewritten);
}
if let Some(rewritten) = rewrite_search_command(cmd, binary) {
return Some(rewritten);
}
if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
return Some(rewritten);
}
if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
return Some(rewritten);
}
if !is_compound(cmd) && is_rewritable(cmd) {
return Some(wrap_single_command(cmd, binary));
}
None
}
pub(super) fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
if !rewrite_registry::is_file_read_command(cmd) && !is_powershell_file_read(cmd) {
return None;
}
if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
return None;
}
if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
return None;
}
let parts = shell_tokenize(cmd);
if parts.len() < 2 {
return None;
}
match parts[0].as_str() {
"cat" => {
let path = parts[1..].join(" ");
if is_outside_project_path(&path) {
return None;
}
Some(format!("{binary} read {}", shell_quote(&path)))
}
"head" => {
let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
let (n, path) = parse_head_tail_args(&refs);
let path = path?;
if is_outside_project_path(path) {
return None;
}
let qp = shell_quote(path);
match n {
Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
None => Some(format!("{binary} read {qp} -m lines:1-10")),
}
}
"tail" => {
let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
let (n, path) = parse_head_tail_args(&refs);
let path = path?;
if is_outside_project_path(path) {
return None;
}
let qp = shell_quote(path);
let lines = n.unwrap_or(10);
Some(format!("{binary} read {qp} -m lines:-{lines}"))
}
"Get-Content" | "gc" => rewrite_get_content(&parts, binary),
_ => None,
}
}
fn is_powershell_file_read(cmd: &str) -> bool {
matches!(cmd.split_whitespace().next(), Some("Get-Content" | "gc"))
}
fn rewrite_get_content(parts: &[String], binary: &str) -> Option<String> {
let mut path: Option<String> = None;
let mut head_n: Option<u64> = None;
let mut tail_n: Option<u64> = None;
let mut i = 1;
while i < parts.len() {
if let Some(flag) = parts[i].strip_prefix('-') {
let value = parts.get(i + 1);
match flag.to_ascii_lowercase().as_str() {
"path" | "literalpath" => path = Some(value?.clone()),
"totalcount" | "head" | "first" => head_n = Some(value?.parse().ok()?),
"tail" | "last" => tail_n = Some(value?.parse().ok()?),
_ => return None,
}
i += 2;
} else if path.is_none() {
path = Some(parts[i].clone());
i += 1;
} else {
return None;
}
}
let path = path?;
if is_outside_project_path(&path) || (head_n.is_some() && tail_n.is_some()) {
return None;
}
let qp = shell_quote(&path);
match (head_n, tail_n) {
(Some(n), None) => Some(format!("{binary} read {qp} -m lines:1-{n}")),
(None, Some(n)) => Some(format!("{binary} read {qp} -m lines:-{n}")),
_ => Some(format!("{binary} read {qp}")),
}
}
pub(super) fn is_outside_project_path(path: &str) -> bool {
let trimmed = path.trim();
if trimmed.starts_with('~') {
return true;
}
if trimmed.starts_with('$') {
return true;
}
if trimmed.starts_with("/proc/")
|| trimmed.starts_with("/sys/")
|| trimmed.starts_with("/dev/")
|| trimmed.starts_with("/tmp/")
|| trimmed.starts_with("/var/")
{
return true;
}
if trimmed.starts_with('/') {
if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
return true;
}
if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
return true;
}
}
false
}
pub(super) fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
let mut n: Option<usize> = None;
let mut path: Option<&str> = None;
let mut i = 0;
while i < args.len() {
if args[i] == "-n" && i + 1 < args.len() {
n = args[i + 1].parse().ok();
i += 2;
} else if let Some(num) = args[i].strip_prefix("-n") {
n = num.parse().ok();
i += 1;
} else if args[i].starts_with('-') && args[i].len() > 1 {
if let Ok(num) = args[i][1..].parse::<usize>() {
n = Some(num);
}
i += 1;
} else {
path = Some(args[i]);
i += 1;
}
}
(n, path)
}
pub(super) fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
let segments = compound_lexer::split_compound(cmd);
let commands: Vec<&str> = segments
.iter()
.filter_map(|s| match s {
compound_lexer::Segment::Command(c) => Some(c.trim()),
compound_lexer::Segment::Operator(_) => None,
})
.collect();
if segments.len() == commands.len() {
return None;
}
let is_leanctx = |c: &str| c.starts_with("lean-ctx ") || c.starts_with(&format!("{binary} "));
if commands.iter().any(|c| is_leanctx(c)) {
return None;
}
if !commands.iter().any(|c| is_rewritable(c)) {
return None;
}
if crate::core::shell_allowlist::passes_enforced(cmd) {
Some(wrap_single_command(cmd, binary))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::rewrite_candidate;
#[test]
fn disabled_prefix_skips_rewrite() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("LEAN_CTX_DISABLED=1 cargo test --lib", binary).is_none(),
"LEAN_CTX_DISABLED prefix must skip rewrite"
);
}
#[test]
fn no_hook_prefix_skips_rewrite() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("LEAN_CTX_NO_HOOK=1 cargo test --lib", binary).is_none(),
"LEAN_CTX_NO_HOOK prefix must skip rewrite"
);
}
#[test]
fn disabled_with_multiple_env_vars_skips_rewrite() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("FOO=bar LEAN_CTX_DISABLED=1 cargo test --lib", binary).is_none(),
"LEAN_CTX_DISABLED anywhere in env prefix must skip rewrite"
);
}
#[test]
fn normal_env_prefix_still_rewrites() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("FOO=bar cargo test --lib", binary).is_some(),
"Non-disable env prefix must still rewrite"
);
}
#[test]
fn no_prefix_still_rewrites() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("cargo test --lib", binary).is_some(),
"Command without env prefix must still rewrite"
);
}
#[test]
fn lean_ctx_command_not_rewritten() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("lean-ctx ls src/", binary).is_none(),
"lean-ctx commands must not be rewritten"
);
}
#[test]
fn redirect_to_file_skips_rewrite() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("git show HEAD:README.md > /tmp/out.md", binary).is_none(),
"stdout redirect to file must skip rewrite"
);
}
#[test]
fn append_redirect_skips_rewrite() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("echo hello >> /tmp/log.txt", binary).is_none(),
"append redirect must skip rewrite"
);
}
#[test]
fn dev_null_redirect_still_rewrites() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("cargo test 2>/dev/null", binary).is_some(),
"/dev/null redirect must still rewrite"
);
}
#[test]
fn stderr_redirect_still_rewrites() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("cargo test 2> /tmp/err.log", binary).is_some(),
"stderr-only redirect must still rewrite"
);
}
#[test]
fn fd_dup_still_rewrites() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("cargo test 2>&1", binary).is_some(),
"fd duplication (2>&1) must still rewrite"
);
}
#[test]
fn quoted_redirect_not_detected() {
let binary = "/Users/test/.local/bin/lean-ctx";
assert!(
rewrite_candidate("echo 'output > file.txt' | grep output", binary).is_some(),
"redirect inside quotes must not trigger skip"
);
}
#[test]
fn redirect_detection_basic() {
use super::has_stdout_file_redirect;
assert!(has_stdout_file_redirect("git status > files.txt"));
assert!(has_stdout_file_redirect("git diff >> changes.log"));
assert!(!has_stdout_file_redirect("git status"));
assert!(!has_stdout_file_redirect("git status 2>/dev/null"));
assert!(!has_stdout_file_redirect("git status > /dev/null"));
assert!(!has_stdout_file_redirect("git status 2>&1"));
assert!(!has_stdout_file_redirect("echo 'a > b'"));
assert!(!has_stdout_file_redirect("echo \"a > b\""));
}
}