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}'")
}
}
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;
}
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
}
}