lean_ctx/hook_handlers/
mod.rs1use crate::core::debug_log::{self, Route};
2use std::io::Read;
3use std::sync::mpsc;
4use std::time::Duration;
5
6const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
7
8const HOOK_GATING_TIMEOUT: Duration = Duration::from_secs(15);
14mod codex;
15mod dedup;
16mod deny;
17mod edit_health;
18mod file_rewrite;
21mod observe;
22mod payload;
23mod read_dedup;
25mod redirect;
26mod search_rewrite;
29mod vibe;
30pub(crate) use codex::emit_session_start_additional_context;
31pub use codex::{handle_codex_pretooluse, handle_codex_session_start};
32pub use vibe::handle_vibe_pre_tool;
33#[cfg(test)]
36pub(crate) use codex::{CODEX_SHELL_RECOVERY_HINT, session_start_additional_context_json};
37pub use deny::handle_deny;
38pub use observe::*;
39pub use read_dedup::handle_read_dedup;
40pub use search_rewrite::{shell_quote, shell_tokenize};
41#[cfg(test)]
42mod tests;
43
44#[cfg(test)]
49use codex::{codex_allow_output, codex_deny_output, codex_rewrite_output};
50#[cfg(test)]
51use file_rewrite::{
52 build_rewrite_compound, is_outside_project_path, is_rewritable, parse_head_tail_args,
53 rewrite_candidate, rewrite_file_read_command, rewrite_skip_reason, wrap_single_command,
54};
55#[cfg(test)]
56use redirect::{
57 RedirectKind, build_redirect_output, classify_redirect, grep_content_mode, redirect_read,
58 redirect_read_args, should_passthrough, warm_daemon_cache,
59};
60#[cfg(test)]
61use search_rewrite::{rewrite_dir_list_command, rewrite_search_command};
62
63fn is_disabled() -> bool {
64 std::env::var("LEAN_CTX_DISABLED").is_ok()
65}
66
67fn is_harden_active() -> bool {
68 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
69}
70
71fn is_shadow_mode_active() -> bool {
72 if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
73 return true;
74 }
75 crate::core::config::Config::load().shadow_mode
76}
77
78fn log_shadow_intercept(tool: &str, detail: &str) {
79 if !is_shadow_mode_active() {
80 return;
81 }
82 let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
83 return;
84 };
85 let log_path = data_dir.join("shadow.log");
86 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
87 let line = format!("[{ts}] intercepted {tool}: {detail}\n");
88 let _ = std::fs::OpenOptions::new()
89 .create(true)
90 .append(true)
91 .open(log_path)
92 .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
93}
94
95fn is_quiet() -> bool {
96 crate::core::runtime_flags::quiet_enabled()
97}
98
99pub fn mark_hook_environment() {
102 crate::core::runtime_flags::mark_hook_child();
103}
104
105pub fn arm_watchdog(timeout: Duration) {
110 std::thread::spawn(move || {
111 std::thread::sleep(timeout);
112 eprintln!(
113 "[lean-ctx hook] watchdog timeout after {}s — force exit",
114 timeout.as_secs()
115 );
116 std::process::exit(1);
117 });
118}
119
120fn emit_gating_decision<F>(timeout: Duration, work: F)
130where
131 F: FnOnce() -> String + Send + 'static,
132{
133 let out = decide_with_timeout(timeout, build_dual_allow_output(), work);
134 print!("{out}");
135}
136
137fn decide_with_timeout<F>(timeout: Duration, fallback: String, work: F) -> String
143where
144 F: FnOnce() -> String + Send + 'static,
145{
146 let (tx, rx) = mpsc::channel();
147 std::thread::spawn(move || {
148 let _ = tx.send(work());
149 });
150 rx.recv_timeout(timeout).unwrap_or(fallback)
151}
152
153fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
155 let (tx, rx) = mpsc::channel();
156 std::thread::spawn(move || {
157 let mut buf = String::new();
158 let result = std::io::stdin().read_to_string(&mut buf);
159 let _ = tx.send(result.ok().map(|_| buf));
160 });
161 match rx.recv_timeout(timeout) {
162 Ok(Some(s)) if !s.is_empty() => Some(s),
163 _ => None,
164 }
165}
166
167fn build_dual_allow_output() -> String {
168 serde_json::json!({
169 "permission": "allow",
170 "hookSpecificOutput": {
171 "hookEventName": "PreToolUse",
172 "permissionDecision": "allow"
173 }
174 })
175 .to_string()
176}
177
178fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
179 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
180 let mut m = obj.clone();
181 m.insert(
182 "command".to_string(),
183 serde_json::Value::String(rewritten.to_string()),
184 );
185 serde_json::Value::Object(m)
186 } else {
187 serde_json::json!({ "command": rewritten })
188 };
189
190 serde_json::json!({
191 "permission": "allow",
193 "updated_input": updated_input.clone(),
194 "permissionDecision": "allow",
199 "modifiedArgs": updated_input.clone(),
200 "hookSpecificOutput": {
202 "hookEventName": "PreToolUse",
203 "permissionDecision": "allow",
204 "updatedInput": updated_input
205 }
206 })
207 .to_string()
208}
209
210fn is_shell_tool(tool_name: &str) -> bool {
216 matches!(
217 tool_name,
218 "Bash"
219 | "bash"
220 | "Shell"
221 | "shell"
222 | "sh"
223 | "runInTerminal"
224 | "run_in_terminal"
225 | "run_terminal"
226 | "runterminal"
227 | "run_command"
228 | "run_shell_command"
229 | "run_terminal_command"
230 | "execute_command"
231 | "exec_command"
232 | "command_exec"
233 | "run"
234 | "exec"
235 | "execute"
236 | "command"
237 | "cmd"
238 | "terminal"
239 | "PowerShell"
240 | "powershell"
241 | "pwsh"
242 )
243}
244
245pub fn handle_rewrite() {
246 emit_gating_decision(HOOK_GATING_TIMEOUT, file_rewrite::compute_rewrite);
247}
248
249pub fn handle_redirect() {
250 emit_gating_decision(HOOK_GATING_TIMEOUT, redirect::compute_redirect);
251}
252
253pub fn handle_copilot() {
262 if is_disabled() {
263 return;
264 }
265 let binary = resolve_binary();
266 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
267 return;
268 };
269
270 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
271 return;
272 };
273
274 let Some(tool_name) = payload::resolve_tool_name(&v) else {
275 return;
276 };
277
278 if !is_shell_tool(&tool_name) {
279 return;
280 }
281
282 let tool_args = payload::resolve_tool_args(&v);
283 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
284 return;
285 };
286
287 if let Some(rewritten) = file_rewrite::rewrite_candidate(&cmd, &binary) {
288 print!(
289 "{}",
290 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
291 );
292 }
293}
294
295pub fn handle_rewrite_inline() {
298 if is_disabled() {
299 return;
300 }
301 let binary = resolve_binary();
302 let args: Vec<String> = std::env::args().collect();
303 if args.len() < 4 {
305 return;
306 }
307 let cmd = args[3..].join(" ");
308
309 if let Some(rewritten) = file_rewrite::rewrite_candidate(&cmd, &binary) {
310 print!("{rewritten}");
311 return;
312 }
313
314 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
315 print!("{cmd}");
316 return;
317 }
318
319 print!("{cmd}");
320}
321
322fn resolve_binary() -> String {
329 crate::core::portable_binary::resolve_portable_binary()
330}
331
332#[cfg(test)]
333fn extract_json_field(input: &str, field: &str) -> Option<String> {
334 let key = format!("\"{field}\":");
335 let key_pos = input.find(&key)?;
336 let after_colon = &input[key_pos + key.len()..];
337 let trimmed = after_colon.trim_start();
338 if !trimmed.starts_with('"') {
339 return None;
340 }
341 let rest = &trimmed[1..];
342 let bytes = rest.as_bytes();
343 let mut end = 0;
344 while end < bytes.len() {
345 if bytes[end] == b'\\' && end + 1 < bytes.len() {
346 end += 2;
347 continue;
348 }
349 if bytes[end] == b'"' {
350 break;
351 }
352 end += 1;
353 }
354 if end >= bytes.len() {
355 return None;
356 }
357 let raw = &rest[..end];
358 Some(unescape_json_string(raw))
359}
360
361#[cfg(test)]
367fn unescape_json_string(s: &str) -> String {
368 let mut out = String::with_capacity(s.len());
369 let mut chars = s.chars();
370 while let Some(c) = chars.next() {
371 if c == '\\' {
372 match chars.next() {
373 Some('n') => out.push('\n'),
374 Some('t') => out.push('\t'),
375 Some('r') => out.push('\r'),
376 Some('"') => out.push('"'),
377 Some('/') => out.push('/'),
378 Some('\\') | None => out.push('\\'),
379 Some(other) => {
380 out.push('\\');
381 out.push(other);
382 }
383 }
384 } else {
385 out.push(c);
386 }
387 }
388 out
389}