use crate::compound_lexer;
use crate::core::debug_log::{self, Route};
use crate::rewrite_registry;
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;
const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
const HOOK_GATING_TIMEOUT: Duration = Duration::from_secs(15);
mod dedup;
mod edit_health;
mod observe;
mod payload;
pub use observe::*;
#[cfg(test)]
mod tests;
fn is_disabled() -> bool {
std::env::var("LEAN_CTX_DISABLED").is_ok()
}
fn is_harden_active() -> bool {
matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
}
fn is_shadow_mode_active() -> bool {
if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
return true;
}
crate::core::config::Config::load().shadow_mode
}
fn log_shadow_intercept(tool: &str, detail: &str) {
if !is_shadow_mode_active() {
return;
}
let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
return;
};
let log_path = data_dir.join("shadow.log");
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("[{ts}] intercepted {tool}: {detail}\n");
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
}
fn is_quiet() -> bool {
matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
}
pub fn mark_hook_environment() {
unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
}
pub fn arm_watchdog(timeout: Duration) {
std::thread::spawn(move || {
std::thread::sleep(timeout);
eprintln!(
"[lean-ctx hook] watchdog timeout after {}s — force exit",
timeout.as_secs()
);
std::process::exit(1);
});
}
fn emit_gating_decision<F>(timeout: Duration, work: F)
where
F: FnOnce() -> String + Send + 'static,
{
let out = decide_with_timeout(timeout, build_dual_allow_output(), work);
print!("{out}");
}
fn decide_with_timeout<F>(timeout: Duration, fallback: String, work: F) -> String
where
F: FnOnce() -> String + Send + 'static,
{
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(work());
});
rx.recv_timeout(timeout).unwrap_or(fallback)
}
fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = String::new();
let result = std::io::stdin().read_to_string(&mut buf);
let _ = tx.send(result.ok().map(|_| buf));
});
match rx.recv_timeout(timeout) {
Ok(Some(s)) if !s.is_empty() => Some(s),
_ => None,
}
}
fn build_dual_allow_output() -> String {
serde_json::json!({
"permission": "allow",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
})
.to_string()
}
fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
let mut m = obj.clone();
m.insert(
"command".to_string(),
serde_json::Value::String(rewritten.to_string()),
);
serde_json::Value::Object(m)
} else {
serde_json::json!({ "command": rewritten })
};
serde_json::json!({
"permission": "allow",
"updated_input": updated_input.clone(),
"permissionDecision": "allow",
"modifiedArgs": updated_input.clone(),
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": updated_input
}
})
.to_string()
}
fn is_shell_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"Bash"
| "bash"
| "Shell"
| "shell"
| "runInTerminal"
| "run_in_terminal"
| "terminal"
| "PowerShell"
| "powershell"
| "pwsh"
)
}
pub fn handle_rewrite() {
emit_gating_decision(HOOK_GATING_TIMEOUT, compute_rewrite);
}
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()
}
})
}
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"
}
}
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(_)))
}
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 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
}
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}")),
}
}
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
}
fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
let parts = shell_tokenize(cmd);
match parts.first().map(String::as_str) {
Some("rg") => {
if parts.len() < 2 || parts.len() > 3 || parts[1].starts_with('-') {
return None;
}
let pattern = &parts[1];
match parts.get(2) {
Some(p) if p.starts_with('-') => None,
Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
None => Some(format!("{binary} grep {pattern}")),
}
}
Some("Select-String" | "sls") => rewrite_select_string(&parts, binary),
_ => None,
}
}
fn rewrite_select_string(parts: &[String], binary: &str) -> Option<String> {
let mut pattern: Option<String> = None;
let mut path: Option<String> = 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() {
"pattern" => pattern = Some(value?.clone()),
"path" | "literalpath" => path = Some(value?.clone()),
_ => return None,
}
i += 2;
} else if pattern.is_none() {
pattern = Some(parts[i].clone());
i += 1;
} else if path.is_none() {
path = Some(parts[i].clone());
i += 1;
} else {
return None;
}
}
let pattern = shell_quote(&pattern?);
match path {
Some(p) if is_outside_project_path(&p) => None,
Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(&p))),
None => Some(format!("{binary} grep {pattern}")),
}
}
fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
let parts = shell_tokenize(cmd);
match parts.first().map(String::as_str) {
Some("ls") => match parts.len() {
1 => Some(format!("{binary} ls")),
2 if !parts[1].starts_with('-') => {
Some(format!("{binary} ls {}", shell_quote(&parts[1])))
}
_ => None,
},
Some("Get-ChildItem" | "gci") => rewrite_get_childitem(&parts, binary),
_ => None,
}
}
fn rewrite_get_childitem(parts: &[String], binary: &str) -> Option<String> {
let mut path: Option<String> = 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()),
_ => return None,
}
i += 2;
} else if path.is_none() {
path = Some(parts[i].clone());
i += 1;
} else {
return None;
}
}
match path {
Some(p) => Some(format!("{binary} ls {}", shell_quote(&p))),
None => Some(format!("{binary} ls")),
}
}
pub fn shell_tokenize(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'\\' if !in_single => {
if let Some(next) = chars.next() {
current.push(next);
}
}
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
pub fn shell_quote(s: &str) -> String {
if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
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)
}
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
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RedirectKind {
Read,
Grep,
Glob,
None,
}
fn classify_redirect(tool_name: &str) -> RedirectKind {
match tool_name {
"Read" | "read" | "read_file" | "view" => RedirectKind::Read,
"Grep" | "grep" | "search" | "ripgrep" | "rg" => RedirectKind::Grep,
"Glob" | "glob" => RedirectKind::Glob,
_ => RedirectKind::None,
}
}
pub fn handle_redirect() {
emit_gating_decision(HOOK_GATING_TIMEOUT, compute_redirect);
}
fn compute_redirect() -> String {
if is_disabled() {
let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
return build_dual_allow_output();
}
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 redirect] invalid JSON payload, allowing passthrough");
return build_dual_allow_output();
};
let tool_name = payload::resolve_tool_name(&v).unwrap_or_default();
let tool_args = payload::resolve_tool_args(&v);
let kind = classify_redirect(&tool_name);
if matches!(kind, RedirectKind::None) {
return build_dual_allow_output();
}
let args_json = tool_args
.as_ref()
.map(ToString::to_string)
.unwrap_or_default();
let key_material = format!("{tool_name}\u{0}{args_json}");
dedup::deduped("redirect", &key_material, || {
produce_redirect_output(kind, tool_args.as_ref())
})
}
fn produce_redirect_output(kind: RedirectKind, tool_args: Option<&serde_json::Value>) -> String {
match kind {
RedirectKind::Read => redirect_read(tool_args),
RedirectKind::Grep => redirect_grep(tool_args),
RedirectKind::Glob => redirect_glob(tool_args),
RedirectKind::None => build_dual_allow_output(),
}
}
fn redirect_read_args(path: &str) -> [&str; 4] {
["read", path, "-m", "full"]
}
fn redirect_read(tool_input: Option<&serde_json::Value>) -> String {
let Some((path_field, path)) =
payload::resolve_path_field(tool_input, payload::READ_PATH_FIELDS)
else {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::Native,
"<none>",
"no path in tool input",
);
return build_dual_allow_output();
};
if should_passthrough(&path) {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::Native,
&path,
"passthrough path (sensitive/binary/excluded)",
);
return build_dual_allow_output();
}
let shadow = is_shadow_mode_active();
if is_harden_active() || shadow {
tracing::info!(
"[hook redirect] {} active, redirecting Read through lean-ctx",
if shadow { "shadow mode" } else { "harden mode" }
);
}
let binary = resolve_binary();
let temp_path = redirect_temp_path(&path);
if let Some(output) = run_with_timeout(
&binary,
&redirect_read_args(&path),
REDIRECT_SUBPROCESS_TIMEOUT,
) {
if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
let temp_str = temp_path.to_str().unwrap_or("");
debug_log::log_hook_decision(
"redirect",
"Read",
Route::LeanCtx,
&path,
"redirected to ctx_read",
);
let shadow_note = shadow.then(|| {
format!(
"lean-ctx shadow mode: this Read was served by ctx_read(\"{path}\", \"full\"). Call ctx_read directly for better performance."
)
});
log_shadow_intercept("Read", &path);
return build_redirect_output(tool_input, path_field, temp_str, shadow_note.as_deref());
}
}
debug_log::log_hook_decision(
"redirect",
"Read",
Route::Native,
&path,
"lean-ctx read produced no output",
);
build_dual_allow_output()
}
fn grep_content_mode(tool_input: Option<&serde_json::Value>) -> bool {
tool_input
.and_then(|ti| ti.get("output_mode"))
.and_then(|m| m.as_str())
== Some("content")
}
fn redirect_grep(tool_input: Option<&serde_json::Value>) -> String {
let pattern = tool_input
.and_then(|ti| ti.get("pattern"))
.and_then(|p| p.as_str())
.unwrap_or("");
let search_path = tool_input
.and_then(|ti| ti.get("path"))
.and_then(|p| p.as_str())
.unwrap_or(".");
if pattern.is_empty() {
debug_log::log_hook_decision(
"redirect",
"Grep",
Route::Native,
"<none>",
"no pattern in tool input",
);
return build_dual_allow_output();
}
if !grep_content_mode(tool_input) {
debug_log::log_hook_decision(
"redirect",
"Grep",
Route::Native,
&format!("{pattern} in {search_path}"),
"non-content output_mode — native passthrough (path-swap only valid for content)",
);
if is_shadow_mode_active() {
log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
}
return build_dual_allow_output();
}
let shadow = is_shadow_mode_active();
if is_harden_active() || shadow {
tracing::info!(
"[hook redirect] {} active, redirecting Grep through lean-ctx",
if shadow { "shadow mode" } else { "harden mode" }
);
}
let binary = resolve_binary();
let key = format!("grep:{pattern}:{search_path}");
let temp_path = redirect_temp_path(&key);
if let Some(output) = run_with_timeout(
&binary,
&["grep", pattern, search_path],
REDIRECT_SUBPROCESS_TIMEOUT,
) {
if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
let temp_str = temp_path.to_str().unwrap_or("");
debug_log::log_hook_decision(
"redirect",
"Grep",
Route::LeanCtx,
&format!("{pattern} in {search_path}"),
"redirected to ctx_search",
);
let shadow_note = shadow.then(|| {
format!(
"lean-ctx shadow mode: this Grep was served by ctx_search(\"{pattern}\", \"{search_path}\"). Call ctx_search directly for better performance."
)
});
log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
return build_redirect_output(tool_input, "path", temp_str, shadow_note.as_deref());
}
}
debug_log::log_hook_decision(
"redirect",
"Grep",
Route::Native,
&format!("{pattern} in {search_path}"),
"lean-ctx grep produced no output",
);
build_dual_allow_output()
}
fn redirect_glob(tool_input: Option<&serde_json::Value>) -> String {
let allow = build_dual_allow_output();
let shadow = is_shadow_mode_active();
if !shadow && !is_harden_active() {
return allow;
}
let pattern = tool_input
.and_then(|ti| ti.get("pattern"))
.and_then(|p| p.as_str())
.unwrap_or("");
if pattern.is_empty() {
debug_log::log_hook_decision(
"redirect",
"Glob",
Route::Native,
"<none>",
"no pattern in tool input",
);
return allow;
}
let search_path = tool_input
.and_then(|ti| ti.get("path"))
.and_then(|p| p.as_str())
.unwrap_or(".");
tracing::info!(
"[hook redirect] {} active, warming ctx_glob for {pattern}",
if shadow { "shadow mode" } else { "harden mode" }
);
let binary = resolve_binary();
let _ = run_with_timeout(
&binary,
&["glob", pattern, search_path],
REDIRECT_SUBPROCESS_TIMEOUT,
);
debug_log::log_hook_decision(
"redirect",
"Glob",
Route::Native,
&format!("{pattern} in {search_path}"),
"shadow/harden warm — native passthrough",
);
log_shadow_intercept("Glob", &format!("{pattern} in {search_path}"));
allow
}
const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
let mut child = std::process::Command::new(binary)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => {
let mut stdout = Vec::new();
if let Some(mut out) = child.stdout.take() {
let _ = out.read_to_end(&mut stdout);
}
return if stdout.is_empty() {
None
} else {
Some(stdout)
};
}
Ok(Some(_)) | Err(_) => return None,
Ok(None) => {
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(10));
}
}
}
}
fn redirect_temp_path(key: &str) -> std::path::PathBuf {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
std::process::id().hash(&mut hasher);
let hash = hasher.finish();
let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
let _ = std::fs::create_dir_all(&temp_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
}
temp_dir.join(format!("{hash:016x}.lctx"))
}
fn build_redirect_output(
tool_input: Option<&serde_json::Value>,
field: &str,
temp_path: &str,
shadow_note: Option<&str>,
) -> String {
let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
let mut m = obj.clone();
m.insert(
field.to_string(),
serde_json::Value::String(temp_path.to_string()),
);
serde_json::Value::Object(m)
} else {
serde_json::json!({ field: temp_path })
};
let mut hook_specific = serde_json::json!({
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": updated_input.clone(),
});
if let Some(note) = shadow_note {
hook_specific["additionalContext"] = serde_json::Value::String(note.to_string());
}
serde_json::json!({
"permission": "allow",
"updated_input": updated_input.clone(),
"permissionDecision": "allow",
"modifiedArgs": updated_input.clone(),
"hookSpecificOutput": hook_specific
})
.to_string()
}
const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
".cursorrules",
".cursor/rules",
".cursor/hooks",
"skill.md",
"agents.md",
".env",
"hooks.json",
"node_modules",
];
const PASSTHROUGH_EXTENSIONS: &[&str] = &[
"lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
];
fn should_passthrough(path: &str) -> bool {
let p = path.to_lowercase();
if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
return true;
}
std::path::Path::new(&p)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
PASSTHROUGH_EXTENSIONS
.iter()
.any(|e| ext.eq_ignore_ascii_case(e))
})
}
fn codex_rewrite_output(rewritten: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"command": rewritten
}
}
})
.to_string()
}
pub fn handle_codex_pretooluse() {
if is_disabled() {
return;
}
let binary = resolve_binary();
let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
return;
};
let tool = extract_json_field(&input, "tool_name");
if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
return;
}
let Some(cmd) = extract_json_field(&input, "command") else {
return;
};
if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
print!("{}", codex_rewrite_output(&rewritten));
}
}
pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": additional_context,
}
})
.to_string()
}
pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
println!(
"{}",
session_start_additional_context_json(additional_context)
);
}
pub fn handle_codex_session_start() {
if is_quiet() {
return;
}
if crate::core::config::Config::load().dedicated_session_context_active() {
return;
}
emit_session_start_additional_context(
"For shell commands matched by lean-ctx compression rules, prefer `lean-ctx -c \"<command>\"`. If a Bash call is blocked, rerun it with the exact command suggested by the hook.",
);
}
pub fn handle_copilot() {
if is_disabled() {
return;
}
let binary = resolve_binary();
let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
return;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
return;
};
let Some(tool_name) = payload::resolve_tool_name(&v) else {
return;
};
if !is_shell_tool(&tool_name) {
return;
}
let tool_args = payload::resolve_tool_args(&v);
let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
return;
};
if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
print!(
"{}",
build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
);
}
}
pub fn handle_rewrite_inline() {
if is_disabled() {
return;
}
let binary = resolve_binary();
let args: Vec<String> = std::env::args().collect();
if args.len() < 4 {
return;
}
let cmd = args[3..].join(" ");
if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
print!("{rewritten}");
return;
}
if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
print!("{cmd}");
return;
}
print!("{cmd}");
}
fn resolve_binary() -> String {
crate::core::portable_binary::resolve_portable_binary()
}
fn extract_json_field(input: &str, field: &str) -> Option<String> {
let key = format!("\"{field}\":");
let key_pos = input.find(&key)?;
let after_colon = &input[key_pos + key.len()..];
let trimmed = after_colon.trim_start();
if !trimmed.starts_with('"') {
return None;
}
let rest = &trimmed[1..];
let bytes = rest.as_bytes();
let mut end = 0;
while end < bytes.len() {
if bytes[end] == b'\\' && end + 1 < bytes.len() {
end += 2;
continue;
}
if bytes[end] == b'"' {
break;
}
end += 1;
}
if end >= bytes.len() {
return None;
}
let raw = &rest[..end];
Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
}