use super::{
HOOK_STDIN_TIMEOUT, build_dual_allow_output, dedup, is_disabled, is_harden_active,
is_shadow_mode_active, is_shadow_surface_active, log_shadow_intercept, payload,
read_stdin_with_timeout, resolve_binary,
};
use crate::core::debug_log::{self, Route};
use std::io::Read;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RedirectKind {
Read,
Grep,
Glob,
None,
}
pub(super) 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" | "list_dir" => RedirectKind::Glob,
_ => RedirectKind::None,
}
}
pub(super) fn compute_redirect() -> String {
if is_disabled() {
let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
return build_dual_allow_output();
}
if is_shadow_surface_active() {
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(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum EditRiskClass {
NeverCompress,
SafeToCompress,
}
pub(super) fn edit_risk_class(path: &str) -> EditRiskClass {
if is_instruction_override(path) {
return EditRiskClass::NeverCompress;
}
EditRiskClass::SafeToCompress
}
fn is_instruction_override(path: &str) -> bool {
let lower = path.to_lowercase();
lower.ends_with("cargo.toml")
|| lower.ends_with("package.json")
|| lower.ends_with("pyproject.toml")
|| lower.ends_with("go.mod")
|| lower.ends_with("makefile")
|| lower.ends_with("dockerfile")
|| lower.ends_with("docker-compose.yml")
|| lower.ends_with("docker-compose.yaml")
|| lower.ends_with(".gitignore")
}
pub(super) fn redirect_read_args(path: &str, _is_windowed: bool) -> Vec<String> {
let mode = match edit_risk_class(path) {
EditRiskClass::SafeToCompress => "auto",
EditRiskClass::NeverCompress => "full",
};
vec![
"read".to_string(),
path.to_string(),
"-m".to_string(),
mode.to_string(),
]
}
pub(super) 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 !crate::core::config::ReadRedirect::read_redirect_enabled(
&crate::core::config::Config::load(),
) {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::Native,
&path,
"read redirect disabled (host guard/config)",
);
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);
let marker = redirect_read_marker(&path);
if marker.exists() {
if let Ok(marker_data) = std::fs::read_to_string(&marker) {
let parts: Vec<&str> = marker_data.splitn(2, '\n').collect();
let stored_mtime = parts.first().unwrap_or(&"");
let current_mtime = file_mtime_str(&path);
if current_mtime.as_str() == *stored_mtime {
if crate::core::config::read_redirect::hook_host_is_cursor() {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::LeanCtx,
&path,
"re-read re-compress (Cursor, no guard)",
);
} else if crate::core::config::ReadRedirect::read_redirect_enabled(
&crate::core::config::Config::load(),
) {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::LeanCtx,
&path,
"re-read re-compress (guard host, read_redirect=on override)",
);
} else {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::Native,
&path,
"re-read passthrough (guard host, edit-safe)",
);
let _ = std::fs::remove_file(&marker);
return build_dual_allow_output();
}
} else {
debug_log::log_hook_decision(
"redirect",
"Read",
Route::LeanCtx,
&path,
"file changed since first read, re-compress",
);
let _ = std::fs::remove_file(&marker);
}
} else {
let _ = std::fs::remove_file(&marker);
}
}
let is_windowed =
tool_input.is_some_and(|v| v.get("offset").is_some() || v.get("limit").is_some());
let args = redirect_read_args(&path, is_windowed);
let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let project_cwd = git_root_for_path(&path);
if let Some(output) = run_with_timeout_cwd(
&binary,
&args_refs,
REDIRECT_SUBPROCESS_TIMEOUT,
project_cwd.as_deref(),
) {
let final_output = output;
let drifting = matches!(
crate::core::data_dir::lean_ctx_data_dir(),
Ok(ref d) if crate::server::bypass_hint::model_is_drifting(d)
);
if !final_output.is_empty() && std::fs::write(&temp_path, &final_output).is_ok() {
let temp_str = temp_path.to_str().unwrap_or("");
let risk = edit_risk_class(&path);
match risk {
EditRiskClass::SafeToCompress => {
if let Ok(orig_bytes) = std::fs::read(&path) {
let digest = crate::core::edit_snapshot::store(&path, &orig_bytes);
crate::core::read_provenance::record_read(&path, "auto", true, digest);
}
}
EditRiskClass::NeverCompress => {
crate::core::read_provenance::record_read(&path, "full", false, None);
crate::core::edit_snapshot::remove(&path);
}
}
warm_daemon_cache(&path);
debug_log::log_hook_decision(
"redirect",
"Read",
Route::LeanCtx,
&path,
"redirected to ctx_read",
);
let note = if !inject_context_allowed() {
None
} else if shadow {
Some(format!(
"lean-ctx shadow mode: this Read was served by ctx_read(\"{path}\", \"full\"). Call ctx_read directly for better performance."
))
} else if drifting {
Some(
crate::server::bypass_hint::REDIRECT_SUFFIX
.trim()
.to_string(),
)
} else {
None
};
log_shadow_intercept("Read", &path);
let _ = std::fs::write(&marker, format!("{}\n1", file_mtime_str(&path)));
return build_redirect_output(tool_input, path_field, temp_str, note.as_deref());
}
}
debug_log::log_hook_decision(
"redirect",
"Read",
Route::Native,
&path,
"lean-ctx read produced no output",
);
build_dual_allow_output()
}
pub(super) fn grep_content_mode(tool_input: Option<&serde_json::Value>) -> bool {
let Some(ti) = tool_input else {
return false;
};
match ti.get("output_mode").and_then(|m| m.as_str()) {
Some("content") => true,
Some(_) => false,
None => crate::core::config::read_redirect::hook_host_is_cursor(),
}
}
pub(super) 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(|| {
inject_context_allowed().then(|| {
format!(
"lean-ctx shadow mode: this Grep was served by ctx_search(\"{pattern}\", \"{search_path}\"). Call ctx_search directly for better performance."
)
})
})
.flatten();
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()
}
pub(super) 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 git_root_for_path(path: &str) -> Option<std::path::PathBuf> {
let p = std::path::Path::new(path);
let start = if p.is_file() { p.parent()? } else { p };
for ancestor in start.ancestors() {
if ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
}
None
}
fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
run_with_timeout_cwd(binary, args, timeout, None)
}
fn run_with_timeout_cwd(
binary: &str,
args: &[&str],
timeout: Duration,
cwd: Option<&std::path::Path>,
) -> Option<Vec<u8>> {
let mut cmd = std::process::Command::new(binary);
cmd.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let mut child = cmd.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 file_mtime_str(path: &str) -> String {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos().to_string())
.unwrap_or_default()
}
fn redirect_read_marker(path: &str) -> std::path::PathBuf {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
path.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);
temp_dir.join(format!("{hash:016x}.read-marker"))
}
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 inject_context_allowed() -> bool {
std::env::var("LEAN_CTX_INJECT_CONTEXT").is_ok()
|| crate::core::config::Config::load()
.code_health
.inject_context
}
pub(super) 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",
];
pub(super) fn should_passthrough(path: &str) -> bool {
let p = path.to_lowercase();
if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
return true;
}
if crate::core::pathjail::is_harness_auto_memory_path(std::path::Path::new(path)) {
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))
})
}
pub(super) fn warm_daemon_cache(path: &str) {
use std::process::{Command, Stdio};
let binary = resolve_binary();
let _ = Command::new(&binary)
.args(["read", path, "-m", "auto"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}