1use crate::compound_lexer;
2use crate::core::debug_log::{self, Route};
3use crate::rewrite_registry;
4use std::io::Read;
5use std::sync::mpsc;
6use std::time::Duration;
7
8const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
9
10const HOOK_GATING_TIMEOUT: Duration = Duration::from_secs(15);
16mod dedup;
17mod edit_health;
18mod observe;
19mod payload;
20pub use observe::*;
21#[cfg(test)]
22mod tests;
23
24fn is_disabled() -> bool {
25 std::env::var("LEAN_CTX_DISABLED").is_ok()
26}
27
28fn is_harden_active() -> bool {
29 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
30}
31
32fn is_shadow_mode_active() -> bool {
33 if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
34 return true;
35 }
36 crate::core::config::Config::load().shadow_mode
37}
38
39fn log_shadow_intercept(tool: &str, detail: &str) {
40 if !is_shadow_mode_active() {
41 return;
42 }
43 let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
44 return;
45 };
46 let log_path = data_dir.join("shadow.log");
47 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
48 let line = format!("[{ts}] intercepted {tool}: {detail}\n");
49 let _ = std::fs::OpenOptions::new()
50 .create(true)
51 .append(true)
52 .open(log_path)
53 .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
54}
55
56fn is_quiet() -> bool {
57 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
58}
59
60pub fn mark_hook_environment() {
63 unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
66}
67
68pub fn arm_watchdog(timeout: Duration) {
73 std::thread::spawn(move || {
74 std::thread::sleep(timeout);
75 eprintln!(
76 "[lean-ctx hook] watchdog timeout after {}s — force exit",
77 timeout.as_secs()
78 );
79 std::process::exit(1);
80 });
81}
82
83fn emit_gating_decision<F>(timeout: Duration, work: F)
93where
94 F: FnOnce() -> String + Send + 'static,
95{
96 let out = decide_with_timeout(timeout, build_dual_allow_output(), work);
97 print!("{out}");
98}
99
100fn decide_with_timeout<F>(timeout: Duration, fallback: String, work: F) -> String
106where
107 F: FnOnce() -> String + Send + 'static,
108{
109 let (tx, rx) = mpsc::channel();
110 std::thread::spawn(move || {
111 let _ = tx.send(work());
112 });
113 rx.recv_timeout(timeout).unwrap_or(fallback)
114}
115
116fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
118 let (tx, rx) = mpsc::channel();
119 std::thread::spawn(move || {
120 let mut buf = String::new();
121 let result = std::io::stdin().read_to_string(&mut buf);
122 let _ = tx.send(result.ok().map(|_| buf));
123 });
124 match rx.recv_timeout(timeout) {
125 Ok(Some(s)) if !s.is_empty() => Some(s),
126 _ => None,
127 }
128}
129
130fn build_dual_allow_output() -> String {
131 serde_json::json!({
132 "permission": "allow",
133 "hookSpecificOutput": {
134 "hookEventName": "PreToolUse",
135 "permissionDecision": "allow"
136 }
137 })
138 .to_string()
139}
140
141fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
142 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
143 let mut m = obj.clone();
144 m.insert(
145 "command".to_string(),
146 serde_json::Value::String(rewritten.to_string()),
147 );
148 serde_json::Value::Object(m)
149 } else {
150 serde_json::json!({ "command": rewritten })
151 };
152
153 serde_json::json!({
154 "permission": "allow",
156 "updated_input": updated_input.clone(),
157 "permissionDecision": "allow",
162 "modifiedArgs": updated_input.clone(),
163 "hookSpecificOutput": {
165 "hookEventName": "PreToolUse",
166 "permissionDecision": "allow",
167 "updatedInput": updated_input
168 }
169 })
170 .to_string()
171}
172
173fn is_shell_tool(tool_name: &str) -> bool {
179 matches!(
180 tool_name,
181 "Bash"
182 | "bash"
183 | "Shell"
184 | "shell"
185 | "runInTerminal"
186 | "run_in_terminal"
187 | "terminal"
188 | "PowerShell"
189 | "powershell"
190 | "pwsh"
191 )
192}
193
194pub fn handle_rewrite() {
195 emit_gating_decision(HOOK_GATING_TIMEOUT, compute_rewrite);
196}
197
198fn compute_rewrite() -> String {
201 if is_disabled() {
202 return build_dual_allow_output();
203 }
204 let binary = resolve_binary();
205 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
206 return build_dual_allow_output();
207 };
208
209 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
210 tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
211 return build_dual_allow_output();
212 };
213
214 let Some(tool_name) = payload::resolve_tool_name(&v) else {
218 return build_dual_allow_output();
219 };
220
221 if !is_shell_tool(&tool_name) {
222 return build_dual_allow_output();
223 }
224
225 let tool_args = payload::resolve_tool_args(&v);
226 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
227 return build_dual_allow_output();
228 };
229
230 let key_material = format!("{tool_name}\u{0}{cmd}");
233 dedup::deduped("rewrite", &key_material, || {
234 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
235 debug_log::log_hook_decision(
236 "rewrite",
237 &tool_name,
238 Route::LeanCtx,
239 &cmd,
240 "rewritable command",
241 );
242 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
243 } else {
244 debug_log::log_hook_decision(
245 "rewrite",
246 &tool_name,
247 Route::Native,
248 &cmd,
249 rewrite_skip_reason(&cmd),
250 );
251 build_dual_allow_output()
252 }
253 })
254}
255
256fn rewrite_skip_reason(cmd: &str) -> &'static str {
260 if cmd.starts_with("lean-ctx ") {
261 "already a lean-ctx command"
262 } else if cmd.contains("<<") {
263 "heredoc cannot be rewritten safely"
264 } else if is_compound(cmd) && !crate::core::shell_allowlist::passes_enforced(cmd) {
265 "compound pipes/chains into a non-allowlisted or interpreter sink — left raw for the agent shell"
266 } else {
267 "not a known read/search/list command"
268 }
269}
270
271fn is_rewritable(cmd: &str) -> bool {
272 rewrite_registry::is_rewritable_command(cmd)
273}
274
275fn is_compound(cmd: &str) -> bool {
281 compound_lexer::split_compound(cmd)
282 .iter()
283 .any(|s| matches!(s, compound_lexer::Segment::Operator(_)))
284}
285
286fn wrap_single_command(cmd: &str, binary: &str) -> String {
287 if cfg!(windows) {
288 let escaped = cmd.replace('"', "\\\"");
289 format!("{binary} -c \"{escaped}\"")
290 } else {
291 let shell_escaped = cmd.replace('\'', "'\\''");
292 format!("{binary} -c '{shell_escaped}'")
293 }
294}
295
296fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
297 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
298 return None;
299 }
300
301 if cmd.contains("<<") {
304 return None;
305 }
306
307 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
308 return Some(rewritten);
309 }
310
311 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
312 return Some(rewritten);
313 }
314
315 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
316 return Some(rewritten);
317 }
318
319 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
320 return Some(rewritten);
321 }
322
323 if !is_compound(cmd) && is_rewritable(cmd) {
329 return Some(wrap_single_command(cmd, binary));
330 }
331
332 None
333}
334
335fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
338 if !rewrite_registry::is_file_read_command(cmd) && !is_powershell_file_read(cmd) {
342 return None;
343 }
344
345 if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
347 return None;
348 }
349
350 if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
352 return None;
353 }
354
355 let parts = shell_tokenize(cmd);
356 if parts.len() < 2 {
357 return None;
358 }
359
360 match parts[0].as_str() {
361 "cat" => {
362 let path = parts[1..].join(" ");
363 if is_outside_project_path(&path) {
364 return None;
365 }
366 Some(format!("{binary} read {}", shell_quote(&path)))
367 }
368 "head" => {
369 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
370 let (n, path) = parse_head_tail_args(&refs);
371 let path = path?;
372 if is_outside_project_path(path) {
373 return None;
374 }
375 let qp = shell_quote(path);
376 match n {
377 Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
378 None => Some(format!("{binary} read {qp} -m lines:1-10")),
379 }
380 }
381 "tail" => {
382 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
383 let (n, path) = parse_head_tail_args(&refs);
384 let path = path?;
385 if is_outside_project_path(path) {
386 return None;
387 }
388 let qp = shell_quote(path);
389 let lines = n.unwrap_or(10);
390 Some(format!("{binary} read {qp} -m lines:-{lines}"))
391 }
392 "Get-Content" | "gc" => rewrite_get_content(&parts, binary),
393 _ => None,
394 }
395}
396
397fn is_powershell_file_read(cmd: &str) -> bool {
399 matches!(cmd.split_whitespace().next(), Some("Get-Content" | "gc"))
400}
401
402fn rewrite_get_content(parts: &[String], binary: &str) -> Option<String> {
408 let mut path: Option<String> = None;
409 let mut head_n: Option<u64> = None;
410 let mut tail_n: Option<u64> = None;
411 let mut i = 1;
412 while i < parts.len() {
413 if let Some(flag) = parts[i].strip_prefix('-') {
414 let value = parts.get(i + 1);
415 match flag.to_ascii_lowercase().as_str() {
416 "path" | "literalpath" => path = Some(value?.clone()),
417 "totalcount" | "head" | "first" => head_n = Some(value?.parse().ok()?),
418 "tail" | "last" => tail_n = Some(value?.parse().ok()?),
419 _ => return None,
420 }
421 i += 2;
422 } else if path.is_none() {
423 path = Some(parts[i].clone());
424 i += 1;
425 } else {
426 return None;
427 }
428 }
429 let path = path?;
430 if is_outside_project_path(&path) || (head_n.is_some() && tail_n.is_some()) {
431 return None;
432 }
433 let qp = shell_quote(&path);
434 match (head_n, tail_n) {
435 (Some(n), None) => Some(format!("{binary} read {qp} -m lines:1-{n}")),
436 (None, Some(n)) => Some(format!("{binary} read {qp} -m lines:-{n}")),
437 _ => Some(format!("{binary} read {qp}")),
438 }
439}
440
441fn is_outside_project_path(path: &str) -> bool {
445 let trimmed = path.trim();
446
447 if trimmed.starts_with('~') {
449 return true;
450 }
451
452 if trimmed.starts_with('$') {
454 return true;
455 }
456
457 if trimmed.starts_with("/proc/")
459 || trimmed.starts_with("/sys/")
460 || trimmed.starts_with("/dev/")
461 || trimmed.starts_with("/tmp/")
462 || trimmed.starts_with("/var/")
463 {
464 return true;
465 }
466
467 if trimmed.starts_with('/') {
471 if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
473 return true;
474 }
475 if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
477 return true;
478 }
479 }
480
481 false
482}
483
484fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
487 let parts = shell_tokenize(cmd);
488 match parts.first().map(String::as_str) {
489 Some("rg") => {
490 if parts.len() < 2 || parts.len() > 3 || parts[1].starts_with('-') {
491 return None;
492 }
493 let pattern = &parts[1];
494 match parts.get(2) {
495 Some(p) if p.starts_with('-') => None,
496 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
497 None => Some(format!("{binary} grep {pattern}")),
498 }
499 }
500 Some("Select-String" | "sls") => rewrite_select_string(&parts, binary),
501 _ => None,
502 }
503}
504
505fn rewrite_select_string(parts: &[String], binary: &str) -> Option<String> {
510 let mut pattern: Option<String> = None;
511 let mut path: Option<String> = None;
512 let mut i = 1;
513 while i < parts.len() {
514 if let Some(flag) = parts[i].strip_prefix('-') {
515 let value = parts.get(i + 1);
516 match flag.to_ascii_lowercase().as_str() {
517 "pattern" => pattern = Some(value?.clone()),
518 "path" | "literalpath" => path = Some(value?.clone()),
519 _ => return None,
520 }
521 i += 2;
522 } else if pattern.is_none() {
523 pattern = Some(parts[i].clone());
524 i += 1;
525 } else if path.is_none() {
526 path = Some(parts[i].clone());
527 i += 1;
528 } else {
529 return None;
530 }
531 }
532 let pattern = shell_quote(&pattern?);
533 match path {
534 Some(p) if is_outside_project_path(&p) => None,
535 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(&p))),
536 None => Some(format!("{binary} grep {pattern}")),
537 }
538}
539
540fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
543 let parts = shell_tokenize(cmd);
544 match parts.first().map(String::as_str) {
545 Some("ls") => match parts.len() {
546 1 => Some(format!("{binary} ls")),
547 2 if !parts[1].starts_with('-') => {
548 Some(format!("{binary} ls {}", shell_quote(&parts[1])))
549 }
550 _ => None,
551 },
552 Some("Get-ChildItem" | "gci") => rewrite_get_childitem(&parts, binary),
553 _ => None,
554 }
555}
556
557fn rewrite_get_childitem(parts: &[String], binary: &str) -> Option<String> {
561 let mut path: Option<String> = None;
562 let mut i = 1;
563 while i < parts.len() {
564 if let Some(flag) = parts[i].strip_prefix('-') {
565 let value = parts.get(i + 1);
566 match flag.to_ascii_lowercase().as_str() {
567 "path" | "literalpath" => path = Some(value?.clone()),
568 _ => return None,
569 }
570 i += 2;
571 } else if path.is_none() {
572 path = Some(parts[i].clone());
573 i += 1;
574 } else {
575 return None;
576 }
577 }
578 match path {
579 Some(p) => Some(format!("{binary} ls {}", shell_quote(&p))),
580 None => Some(format!("{binary} ls")),
581 }
582}
583
584pub fn shell_tokenize(input: &str) -> Vec<String> {
586 let mut tokens = Vec::new();
587 let mut current = String::new();
588 let mut chars = input.chars().peekable();
589 let mut in_single = false;
590 let mut in_double = false;
591
592 while let Some(c) = chars.next() {
593 match c {
594 '\'' if !in_double => in_single = !in_single,
595 '"' if !in_single => in_double = !in_double,
596 '\\' if !in_single => {
597 if let Some(next) = chars.next() {
598 current.push(next);
599 }
600 }
601 c if c.is_whitespace() && !in_single && !in_double => {
602 if !current.is_empty() {
603 tokens.push(std::mem::take(&mut current));
604 }
605 }
606 _ => current.push(c),
607 }
608 }
609 if !current.is_empty() {
610 tokens.push(current);
611 }
612 tokens
613}
614
615pub fn shell_quote(s: &str) -> String {
617 if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
618 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
619 } else {
620 s.to_string()
621 }
622}
623
624fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
625 let mut n: Option<usize> = None;
626 let mut path: Option<&str> = None;
627
628 let mut i = 0;
629 while i < args.len() {
630 if args[i] == "-n" && i + 1 < args.len() {
631 n = args[i + 1].parse().ok();
632 i += 2;
633 } else if let Some(num) = args[i].strip_prefix("-n") {
634 n = num.parse().ok();
635 i += 1;
636 } else if args[i].starts_with('-') && args[i].len() > 1 {
637 if let Ok(num) = args[i][1..].parse::<usize>() {
638 n = Some(num);
639 }
640 i += 1;
641 } else {
642 path = Some(args[i]);
643 i += 1;
644 }
645 }
646
647 (n, path)
648}
649
650fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
674 let segments = compound_lexer::split_compound(cmd);
675 let commands: Vec<&str> = segments
676 .iter()
677 .filter_map(|s| match s {
678 compound_lexer::Segment::Command(c) => Some(c.trim()),
679 compound_lexer::Segment::Operator(_) => None,
680 })
681 .collect();
682
683 if segments.len() == commands.len() {
686 return None;
687 }
688
689 let is_leanctx = |c: &str| c.starts_with("lean-ctx ") || c.starts_with(&format!("{binary} "));
690
691 if commands.iter().any(|c| is_leanctx(c)) {
693 return None;
694 }
695
696 if !commands.iter().any(|c| is_rewritable(c)) {
698 return None;
699 }
700
701 if crate::core::shell_allowlist::passes_enforced(cmd) {
704 Some(wrap_single_command(cmd, binary))
705 } else {
706 None
707 }
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712enum RedirectKind {
713 Read,
714 Grep,
715 Glob,
716 None,
717}
718
719fn classify_redirect(tool_name: &str) -> RedirectKind {
726 match tool_name {
727 "Read" | "read" | "read_file" | "view" => RedirectKind::Read,
728 "Grep" | "grep" | "search" | "ripgrep" | "rg" => RedirectKind::Grep,
729 "Glob" | "glob" => RedirectKind::Glob,
730 _ => RedirectKind::None,
731 }
732}
733
734pub fn handle_redirect() {
735 emit_gating_decision(HOOK_GATING_TIMEOUT, compute_redirect);
736}
737
738fn compute_redirect() -> String {
741 if is_disabled() {
742 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
743 return build_dual_allow_output();
744 }
745
746 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
747 return build_dual_allow_output();
748 };
749
750 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
751 tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
752 return build_dual_allow_output();
753 };
754
755 let tool_name = payload::resolve_tool_name(&v).unwrap_or_default();
757 let tool_args = payload::resolve_tool_args(&v);
758
759 let kind = classify_redirect(&tool_name);
760 if matches!(kind, RedirectKind::None) {
761 return build_dual_allow_output();
762 }
763
764 let args_json = tool_args
769 .as_ref()
770 .map(ToString::to_string)
771 .unwrap_or_default();
772 let key_material = format!("{tool_name}\u{0}{args_json}");
773 dedup::deduped("redirect", &key_material, || {
774 produce_redirect_output(kind, tool_args.as_ref())
775 })
776}
777
778fn produce_redirect_output(kind: RedirectKind, tool_args: Option<&serde_json::Value>) -> String {
782 match kind {
783 RedirectKind::Read => redirect_read(tool_args),
784 RedirectKind::Grep => redirect_grep(tool_args),
785 RedirectKind::Glob => redirect_glob(tool_args),
786 RedirectKind::None => build_dual_allow_output(),
787 }
788}
789
790fn redirect_read_args(path: &str) -> [&str; 4] {
799 ["read", path, "-m", "full"]
800}
801
802fn redirect_read(tool_input: Option<&serde_json::Value>) -> String {
806 let Some((path_field, path)) =
810 payload::resolve_path_field(tool_input, payload::READ_PATH_FIELDS)
811 else {
812 debug_log::log_hook_decision(
813 "redirect",
814 "Read",
815 Route::Native,
816 "<none>",
817 "no path in tool input",
818 );
819 return build_dual_allow_output();
820 };
821 if should_passthrough(&path) {
822 debug_log::log_hook_decision(
823 "redirect",
824 "Read",
825 Route::Native,
826 &path,
827 "passthrough path (sensitive/binary/excluded)",
828 );
829 return build_dual_allow_output();
830 }
831
832 let shadow = is_shadow_mode_active();
833 if is_harden_active() || shadow {
834 tracing::info!(
835 "[hook redirect] {} active, redirecting Read through lean-ctx",
836 if shadow { "shadow mode" } else { "harden mode" }
837 );
838 }
839
840 let binary = resolve_binary();
841 let temp_path = redirect_temp_path(&path);
842
843 if let Some(output) = run_with_timeout(
844 &binary,
845 &redirect_read_args(&path),
846 REDIRECT_SUBPROCESS_TIMEOUT,
847 ) {
848 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
854 let temp_str = temp_path.to_str().unwrap_or("");
855 debug_log::log_hook_decision(
856 "redirect",
857 "Read",
858 Route::LeanCtx,
859 &path,
860 "redirected to ctx_read",
861 );
862 let shadow_note = shadow.then(|| {
863 format!(
864 "lean-ctx shadow mode: this Read was served by ctx_read(\"{path}\", \"full\"). Call ctx_read directly for better performance."
865 )
866 });
867 log_shadow_intercept("Read", &path);
868 return build_redirect_output(tool_input, path_field, temp_str, shadow_note.as_deref());
869 }
870 }
871
872 debug_log::log_hook_decision(
873 "redirect",
874 "Read",
875 Route::Native,
876 &path,
877 "lean-ctx read produced no output",
878 );
879 build_dual_allow_output()
880}
881
882fn grep_content_mode(tool_input: Option<&serde_json::Value>) -> bool {
891 tool_input
892 .and_then(|ti| ti.get("output_mode"))
893 .and_then(|m| m.as_str())
894 == Some("content")
895}
896
897fn redirect_grep(tool_input: Option<&serde_json::Value>) -> String {
898 let pattern = tool_input
899 .and_then(|ti| ti.get("pattern"))
900 .and_then(|p| p.as_str())
901 .unwrap_or("");
902 let search_path = tool_input
903 .and_then(|ti| ti.get("path"))
904 .and_then(|p| p.as_str())
905 .unwrap_or(".");
906
907 if pattern.is_empty() {
908 debug_log::log_hook_decision(
909 "redirect",
910 "Grep",
911 Route::Native,
912 "<none>",
913 "no pattern in tool input",
914 );
915 return build_dual_allow_output();
916 }
917
918 if !grep_content_mode(tool_input) {
919 debug_log::log_hook_decision(
920 "redirect",
921 "Grep",
922 Route::Native,
923 &format!("{pattern} in {search_path}"),
924 "non-content output_mode — native passthrough (path-swap only valid for content)",
925 );
926 if is_shadow_mode_active() {
927 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
928 }
929 return build_dual_allow_output();
930 }
931
932 let shadow = is_shadow_mode_active();
933 if is_harden_active() || shadow {
934 tracing::info!(
935 "[hook redirect] {} active, redirecting Grep through lean-ctx",
936 if shadow { "shadow mode" } else { "harden mode" }
937 );
938 }
939
940 let binary = resolve_binary();
941 let key = format!("grep:{pattern}:{search_path}");
942 let temp_path = redirect_temp_path(&key);
943
944 if let Some(output) = run_with_timeout(
945 &binary,
946 &["grep", pattern, search_path],
947 REDIRECT_SUBPROCESS_TIMEOUT,
948 ) {
949 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
953 let temp_str = temp_path.to_str().unwrap_or("");
954 debug_log::log_hook_decision(
955 "redirect",
956 "Grep",
957 Route::LeanCtx,
958 &format!("{pattern} in {search_path}"),
959 "redirected to ctx_search",
960 );
961 let shadow_note = shadow.then(|| {
962 format!(
963 "lean-ctx shadow mode: this Grep was served by ctx_search(\"{pattern}\", \"{search_path}\"). Call ctx_search directly for better performance."
964 )
965 });
966 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
967 return build_redirect_output(tool_input, "path", temp_str, shadow_note.as_deref());
968 }
969 }
970
971 debug_log::log_hook_decision(
972 "redirect",
973 "Grep",
974 Route::Native,
975 &format!("{pattern} in {search_path}"),
976 "lean-ctx grep produced no output",
977 );
978 build_dual_allow_output()
979}
980
981fn redirect_glob(tool_input: Option<&serde_json::Value>) -> String {
996 let allow = build_dual_allow_output();
997 let shadow = is_shadow_mode_active();
998 if !shadow && !is_harden_active() {
999 return allow;
1000 }
1001
1002 let pattern = tool_input
1003 .and_then(|ti| ti.get("pattern"))
1004 .and_then(|p| p.as_str())
1005 .unwrap_or("");
1006 if pattern.is_empty() {
1007 debug_log::log_hook_decision(
1008 "redirect",
1009 "Glob",
1010 Route::Native,
1011 "<none>",
1012 "no pattern in tool input",
1013 );
1014 return allow;
1015 }
1016
1017 let search_path = tool_input
1018 .and_then(|ti| ti.get("path"))
1019 .and_then(|p| p.as_str())
1020 .unwrap_or(".");
1021
1022 tracing::info!(
1023 "[hook redirect] {} active, warming ctx_glob for {pattern}",
1024 if shadow { "shadow mode" } else { "harden mode" }
1025 );
1026
1027 let binary = resolve_binary();
1030 let _ = run_with_timeout(
1031 &binary,
1032 &["glob", pattern, search_path],
1033 REDIRECT_SUBPROCESS_TIMEOUT,
1034 );
1035
1036 debug_log::log_hook_decision(
1037 "redirect",
1038 "Glob",
1039 Route::Native,
1040 &format!("{pattern} in {search_path}"),
1041 "shadow/harden warm — native passthrough",
1042 );
1043 log_shadow_intercept("Glob", &format!("{pattern} in {search_path}"));
1044 allow
1045}
1046
1047const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
1048
1049fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
1052 let mut child = std::process::Command::new(binary)
1053 .args(args)
1054 .stdout(std::process::Stdio::piped())
1055 .stderr(std::process::Stdio::null())
1056 .spawn()
1057 .ok()?;
1058
1059 let deadline = std::time::Instant::now() + timeout;
1060 loop {
1061 match child.try_wait() {
1062 Ok(Some(status)) if status.success() => {
1063 let mut stdout = Vec::new();
1064 if let Some(mut out) = child.stdout.take() {
1065 let _ = out.read_to_end(&mut stdout);
1066 }
1067 return if stdout.is_empty() {
1068 None
1069 } else {
1070 Some(stdout)
1071 };
1072 }
1073 Ok(Some(_)) | Err(_) => return None,
1074 Ok(None) => {
1075 if std::time::Instant::now() > deadline {
1076 let _ = child.kill();
1077 let _ = child.wait();
1078 return None;
1079 }
1080 std::thread::sleep(Duration::from_millis(10));
1081 }
1082 }
1083 }
1084}
1085
1086fn redirect_temp_path(key: &str) -> std::path::PathBuf {
1087 use std::collections::hash_map::DefaultHasher;
1088 use std::hash::{Hash, Hasher};
1089
1090 let mut hasher = DefaultHasher::new();
1091 key.hash(&mut hasher);
1092 std::process::id().hash(&mut hasher);
1093 let hash = hasher.finish();
1094
1095 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
1096 let _ = std::fs::create_dir_all(&temp_dir);
1097 #[cfg(unix)]
1098 {
1099 use std::os::unix::fs::PermissionsExt;
1100 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
1101 }
1102 temp_dir.join(format!("{hash:016x}.lctx"))
1103}
1104
1105fn build_redirect_output(
1106 tool_input: Option<&serde_json::Value>,
1107 field: &str,
1108 temp_path: &str,
1109 shadow_note: Option<&str>,
1110) -> String {
1111 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
1112 let mut m = obj.clone();
1113 m.insert(
1114 field.to_string(),
1115 serde_json::Value::String(temp_path.to_string()),
1116 );
1117 serde_json::Value::Object(m)
1118 } else {
1119 serde_json::json!({ field: temp_path })
1120 };
1121
1122 let mut hook_specific = serde_json::json!({
1124 "hookEventName": "PreToolUse",
1125 "permissionDecision": "allow",
1126 "updatedInput": updated_input.clone(),
1127 });
1128 if let Some(note) = shadow_note {
1132 hook_specific["additionalContext"] = serde_json::Value::String(note.to_string());
1133 }
1134
1135 serde_json::json!({
1136 "permission": "allow",
1138 "updated_input": updated_input.clone(),
1139 "permissionDecision": "allow",
1143 "modifiedArgs": updated_input.clone(),
1144 "hookSpecificOutput": hook_specific
1145 })
1146 .to_string()
1147}
1148
1149const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
1150 ".cursorrules",
1151 ".cursor/rules",
1152 ".cursor/hooks",
1153 "skill.md",
1154 "agents.md",
1155 ".env",
1156 "hooks.json",
1157 "node_modules",
1158];
1159
1160const PASSTHROUGH_EXTENSIONS: &[&str] = &[
1161 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
1162];
1163
1164fn should_passthrough(path: &str) -> bool {
1165 let p = path.to_lowercase();
1166
1167 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
1168 return true;
1169 }
1170
1171 std::path::Path::new(&p)
1172 .extension()
1173 .and_then(|ext| ext.to_str())
1174 .is_some_and(|ext| {
1175 PASSTHROUGH_EXTENSIONS
1176 .iter()
1177 .any(|e| ext.eq_ignore_ascii_case(e))
1178 })
1179}
1180
1181fn codex_rewrite_output(rewritten: &str) -> String {
1182 serde_json::json!({
1183 "hookSpecificOutput": {
1184 "hookEventName": "PreToolUse",
1185 "permissionDecision": "allow",
1186 "updatedInput": {
1187 "command": rewritten
1188 }
1189 }
1190 })
1191 .to_string()
1192}
1193
1194pub fn handle_codex_pretooluse() {
1195 if is_disabled() {
1196 return;
1197 }
1198 let binary = resolve_binary();
1199 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1200 return;
1201 };
1202
1203 let tool = extract_json_field(&input, "tool_name");
1204 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1205 return;
1206 }
1207
1208 let Some(cmd) = extract_json_field(&input, "command") else {
1209 return;
1210 };
1211
1212 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1213 print!("{}", codex_rewrite_output(&rewritten));
1214 }
1215}
1216
1217pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
1227 serde_json::json!({
1228 "hookSpecificOutput": {
1229 "hookEventName": "SessionStart",
1230 "additionalContext": additional_context,
1231 }
1232 })
1233 .to_string()
1234}
1235
1236pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
1237 println!(
1238 "{}",
1239 session_start_additional_context_json(additional_context)
1240 );
1241}
1242
1243pub(crate) const CODEX_SHELL_RECOVERY_HINT: &str = "lean-ctx auto-compresses shell output, and the compression is fully reversible: when you need the complete, exact output, re-run the command as `lean-ctx raw \"<command>\"` instead of reading it back in small chunks. If a Bash call is blocked, rerun it with the exact command the hook suggests.";
1260
1261pub fn handle_codex_session_start() {
1262 if is_quiet() {
1263 return;
1264 }
1265 if crate::core::config::Config::load().dedicated_session_context_active() {
1269 return;
1270 }
1271 emit_session_start_additional_context(CODEX_SHELL_RECOVERY_HINT);
1272}
1273
1274pub fn handle_copilot() {
1283 if is_disabled() {
1284 return;
1285 }
1286 let binary = resolve_binary();
1287 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1288 return;
1289 };
1290
1291 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
1292 return;
1293 };
1294
1295 let Some(tool_name) = payload::resolve_tool_name(&v) else {
1296 return;
1297 };
1298
1299 if !is_shell_tool(&tool_name) {
1300 return;
1301 }
1302
1303 let tool_args = payload::resolve_tool_args(&v);
1304 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
1305 return;
1306 };
1307
1308 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1309 print!(
1310 "{}",
1311 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
1312 );
1313 }
1314}
1315
1316pub fn handle_rewrite_inline() {
1319 if is_disabled() {
1320 return;
1321 }
1322 let binary = resolve_binary();
1323 let args: Vec<String> = std::env::args().collect();
1324 if args.len() < 4 {
1326 return;
1327 }
1328 let cmd = args[3..].join(" ");
1329
1330 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1331 print!("{rewritten}");
1332 return;
1333 }
1334
1335 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1336 print!("{cmd}");
1337 return;
1338 }
1339
1340 print!("{cmd}");
1341}
1342
1343fn resolve_binary() -> String {
1350 crate::core::portable_binary::resolve_portable_binary()
1351}
1352
1353fn extract_json_field(input: &str, field: &str) -> Option<String> {
1354 let key = format!("\"{field}\":");
1355 let key_pos = input.find(&key)?;
1356 let after_colon = &input[key_pos + key.len()..];
1357 let trimmed = after_colon.trim_start();
1358 if !trimmed.starts_with('"') {
1359 return None;
1360 }
1361 let rest = &trimmed[1..];
1362 let bytes = rest.as_bytes();
1363 let mut end = 0;
1364 while end < bytes.len() {
1365 if bytes[end] == b'\\' && end + 1 < bytes.len() {
1366 end += 2;
1367 continue;
1368 }
1369 if bytes[end] == b'"' {
1370 break;
1371 }
1372 end += 1;
1373 }
1374 if end >= bytes.len() {
1375 return None;
1376 }
1377 let raw = &rest[..end];
1378 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1379}