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);
9mod observe;
10mod payload;
11pub use observe::*;
12#[cfg(test)]
13mod tests;
14
15fn is_disabled() -> bool {
16 std::env::var("LEAN_CTX_DISABLED").is_ok()
17}
18
19fn is_harden_active() -> bool {
20 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
21}
22
23fn is_shadow_mode_active() -> bool {
24 if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
25 return true;
26 }
27 crate::core::config::Config::load().shadow_mode
28}
29
30fn log_shadow_intercept(tool: &str, detail: &str) {
31 if !is_shadow_mode_active() {
32 return;
33 }
34 let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
35 return;
36 };
37 let log_path = data_dir.join("shadow.log");
38 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
39 let line = format!("[{ts}] intercepted {tool}: {detail}\n");
40 let _ = std::fs::OpenOptions::new()
41 .create(true)
42 .append(true)
43 .open(log_path)
44 .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
45}
46
47fn is_quiet() -> bool {
48 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
49}
50
51pub fn mark_hook_environment() {
54 unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
57}
58
59pub fn arm_watchdog(timeout: Duration) {
64 std::thread::spawn(move || {
65 std::thread::sleep(timeout);
66 eprintln!(
67 "[lean-ctx hook] watchdog timeout after {}s — force exit",
68 timeout.as_secs()
69 );
70 std::process::exit(1);
71 });
72}
73
74fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
76 let (tx, rx) = mpsc::channel();
77 std::thread::spawn(move || {
78 let mut buf = String::new();
79 let result = std::io::stdin().read_to_string(&mut buf);
80 let _ = tx.send(result.ok().map(|_| buf));
81 });
82 match rx.recv_timeout(timeout) {
83 Ok(Some(s)) if !s.is_empty() => Some(s),
84 _ => None,
85 }
86}
87
88fn build_dual_allow_output() -> String {
89 serde_json::json!({
90 "permission": "allow",
91 "hookSpecificOutput": {
92 "hookEventName": "PreToolUse",
93 "permissionDecision": "allow"
94 }
95 })
96 .to_string()
97}
98
99fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
100 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
101 let mut m = obj.clone();
102 m.insert(
103 "command".to_string(),
104 serde_json::Value::String(rewritten.to_string()),
105 );
106 serde_json::Value::Object(m)
107 } else {
108 serde_json::json!({ "command": rewritten })
109 };
110
111 serde_json::json!({
112 "permission": "allow",
114 "updated_input": updated_input.clone(),
115 "permissionDecision": "allow",
120 "modifiedArgs": updated_input.clone(),
121 "hookSpecificOutput": {
123 "hookEventName": "PreToolUse",
124 "permissionDecision": "allow",
125 "updatedInput": updated_input
126 }
127 })
128 .to_string()
129}
130
131fn is_shell_tool(tool_name: &str) -> bool {
137 matches!(
138 tool_name,
139 "Bash"
140 | "bash"
141 | "Shell"
142 | "shell"
143 | "runInTerminal"
144 | "run_in_terminal"
145 | "terminal"
146 | "PowerShell"
147 | "powershell"
148 | "pwsh"
149 )
150}
151
152pub fn handle_rewrite() {
153 let allow = build_dual_allow_output();
154 if is_disabled() {
155 print!("{allow}");
156 return;
157 }
158 let binary = resolve_binary();
159 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
160 print!("{allow}");
161 return;
162 };
163
164 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
165 tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
166 print!("{allow}");
167 return;
168 };
169
170 let Some(tool_name) = payload::resolve_tool_name(&v) else {
174 print!("{allow}");
175 return;
176 };
177
178 if !is_shell_tool(&tool_name) {
179 print!("{allow}");
180 return;
181 }
182
183 let tool_args = payload::resolve_tool_args(&v);
184 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
185 print!("{allow}");
186 return;
187 };
188
189 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
190 debug_log::log_hook_decision(
191 "rewrite",
192 &tool_name,
193 Route::LeanCtx,
194 &cmd,
195 "rewritable command",
196 );
197 print!(
198 "{}",
199 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
200 );
201 } else {
202 debug_log::log_hook_decision(
203 "rewrite",
204 &tool_name,
205 Route::Native,
206 &cmd,
207 rewrite_skip_reason(&cmd),
208 );
209 print!("{allow}");
210 }
211}
212
213fn rewrite_skip_reason(cmd: &str) -> &'static str {
217 if cmd.starts_with("lean-ctx ") {
218 "already a lean-ctx command"
219 } else if cmd.contains("<<") {
220 "heredoc cannot be rewritten safely"
221 } else {
222 "not a known read/search/list command"
223 }
224}
225
226fn is_rewritable(cmd: &str) -> bool {
227 rewrite_registry::is_rewritable_command(cmd)
228}
229
230fn wrap_single_command(cmd: &str, binary: &str) -> String {
231 if cfg!(windows) {
232 let escaped = cmd.replace('"', "\\\"");
233 format!("{binary} -c \"{escaped}\"")
234 } else {
235 let shell_escaped = cmd.replace('\'', "'\\''");
236 format!("{binary} -c '{shell_escaped}'")
237 }
238}
239
240fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
241 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
242 return None;
243 }
244
245 if cmd.contains("<<") {
248 return None;
249 }
250
251 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
252 return Some(rewritten);
253 }
254
255 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
256 return Some(rewritten);
257 }
258
259 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
260 return Some(rewritten);
261 }
262
263 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
264 return Some(rewritten);
265 }
266
267 if is_rewritable(cmd) {
268 return Some(wrap_single_command(cmd, binary));
269 }
270
271 None
272}
273
274fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
277 if !rewrite_registry::is_file_read_command(cmd) && !is_powershell_file_read(cmd) {
281 return None;
282 }
283
284 if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
286 return None;
287 }
288
289 if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
291 return None;
292 }
293
294 let parts = shell_tokenize(cmd);
295 if parts.len() < 2 {
296 return None;
297 }
298
299 match parts[0].as_str() {
300 "cat" => {
301 let path = parts[1..].join(" ");
302 if is_outside_project_path(&path) {
303 return None;
304 }
305 Some(format!("{binary} read {}", shell_quote(&path)))
306 }
307 "head" => {
308 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
309 let (n, path) = parse_head_tail_args(&refs);
310 let path = path?;
311 if is_outside_project_path(path) {
312 return None;
313 }
314 let qp = shell_quote(path);
315 match n {
316 Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
317 None => Some(format!("{binary} read {qp} -m lines:1-10")),
318 }
319 }
320 "tail" => {
321 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
322 let (n, path) = parse_head_tail_args(&refs);
323 let path = path?;
324 if is_outside_project_path(path) {
325 return None;
326 }
327 let qp = shell_quote(path);
328 let lines = n.unwrap_or(10);
329 Some(format!("{binary} read {qp} -m lines:-{lines}"))
330 }
331 "Get-Content" | "gc" => rewrite_get_content(&parts, binary),
332 _ => None,
333 }
334}
335
336fn is_powershell_file_read(cmd: &str) -> bool {
338 matches!(cmd.split_whitespace().next(), Some("Get-Content" | "gc"))
339}
340
341fn rewrite_get_content(parts: &[String], binary: &str) -> Option<String> {
347 let mut path: Option<String> = None;
348 let mut head_n: Option<u64> = None;
349 let mut tail_n: Option<u64> = None;
350 let mut i = 1;
351 while i < parts.len() {
352 if let Some(flag) = parts[i].strip_prefix('-') {
353 let value = parts.get(i + 1);
354 match flag.to_ascii_lowercase().as_str() {
355 "path" | "literalpath" => path = Some(value?.clone()),
356 "totalcount" | "head" | "first" => head_n = Some(value?.parse().ok()?),
357 "tail" | "last" => tail_n = Some(value?.parse().ok()?),
358 _ => return None,
359 }
360 i += 2;
361 } else if path.is_none() {
362 path = Some(parts[i].clone());
363 i += 1;
364 } else {
365 return None;
366 }
367 }
368 let path = path?;
369 if is_outside_project_path(&path) || (head_n.is_some() && tail_n.is_some()) {
370 return None;
371 }
372 let qp = shell_quote(&path);
373 match (head_n, tail_n) {
374 (Some(n), None) => Some(format!("{binary} read {qp} -m lines:1-{n}")),
375 (None, Some(n)) => Some(format!("{binary} read {qp} -m lines:-{n}")),
376 _ => Some(format!("{binary} read {qp}")),
377 }
378}
379
380fn is_outside_project_path(path: &str) -> bool {
384 let trimmed = path.trim();
385
386 if trimmed.starts_with('~') {
388 return true;
389 }
390
391 if trimmed.starts_with('$') {
393 return true;
394 }
395
396 if trimmed.starts_with("/proc/")
398 || trimmed.starts_with("/sys/")
399 || trimmed.starts_with("/dev/")
400 || trimmed.starts_with("/tmp/")
401 || trimmed.starts_with("/var/")
402 {
403 return true;
404 }
405
406 if trimmed.starts_with('/') {
410 if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
412 return true;
413 }
414 if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
416 return true;
417 }
418 }
419
420 false
421}
422
423fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
426 let parts = shell_tokenize(cmd);
427 match parts.first().map(String::as_str) {
428 Some("rg") => {
429 if parts.len() < 2 || parts.len() > 3 || parts[1].starts_with('-') {
430 return None;
431 }
432 let pattern = &parts[1];
433 match parts.get(2) {
434 Some(p) if p.starts_with('-') => None,
435 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
436 None => Some(format!("{binary} grep {pattern}")),
437 }
438 }
439 Some("Select-String" | "sls") => rewrite_select_string(&parts, binary),
440 _ => None,
441 }
442}
443
444fn rewrite_select_string(parts: &[String], binary: &str) -> Option<String> {
449 let mut pattern: Option<String> = None;
450 let mut path: Option<String> = None;
451 let mut i = 1;
452 while i < parts.len() {
453 if let Some(flag) = parts[i].strip_prefix('-') {
454 let value = parts.get(i + 1);
455 match flag.to_ascii_lowercase().as_str() {
456 "pattern" => pattern = Some(value?.clone()),
457 "path" | "literalpath" => path = Some(value?.clone()),
458 _ => return None,
459 }
460 i += 2;
461 } else if pattern.is_none() {
462 pattern = Some(parts[i].clone());
463 i += 1;
464 } else if path.is_none() {
465 path = Some(parts[i].clone());
466 i += 1;
467 } else {
468 return None;
469 }
470 }
471 let pattern = shell_quote(&pattern?);
472 match path {
473 Some(p) if is_outside_project_path(&p) => None,
474 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(&p))),
475 None => Some(format!("{binary} grep {pattern}")),
476 }
477}
478
479fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
482 let parts = shell_tokenize(cmd);
483 match parts.first().map(String::as_str) {
484 Some("ls") => match parts.len() {
485 1 => Some(format!("{binary} ls")),
486 2 if !parts[1].starts_with('-') => {
487 Some(format!("{binary} ls {}", shell_quote(&parts[1])))
488 }
489 _ => None,
490 },
491 Some("Get-ChildItem" | "gci") => rewrite_get_childitem(&parts, binary),
492 _ => None,
493 }
494}
495
496fn rewrite_get_childitem(parts: &[String], binary: &str) -> Option<String> {
500 let mut path: Option<String> = None;
501 let mut i = 1;
502 while i < parts.len() {
503 if let Some(flag) = parts[i].strip_prefix('-') {
504 let value = parts.get(i + 1);
505 match flag.to_ascii_lowercase().as_str() {
506 "path" | "literalpath" => path = Some(value?.clone()),
507 _ => return None,
508 }
509 i += 2;
510 } else if path.is_none() {
511 path = Some(parts[i].clone());
512 i += 1;
513 } else {
514 return None;
515 }
516 }
517 match path {
518 Some(p) => Some(format!("{binary} ls {}", shell_quote(&p))),
519 None => Some(format!("{binary} ls")),
520 }
521}
522
523pub fn shell_tokenize(input: &str) -> Vec<String> {
525 let mut tokens = Vec::new();
526 let mut current = String::new();
527 let mut chars = input.chars().peekable();
528 let mut in_single = false;
529 let mut in_double = false;
530
531 while let Some(c) = chars.next() {
532 match c {
533 '\'' if !in_double => in_single = !in_single,
534 '"' if !in_single => in_double = !in_double,
535 '\\' if !in_single => {
536 if let Some(next) = chars.next() {
537 current.push(next);
538 }
539 }
540 c if c.is_whitespace() && !in_single && !in_double => {
541 if !current.is_empty() {
542 tokens.push(std::mem::take(&mut current));
543 }
544 }
545 _ => current.push(c),
546 }
547 }
548 if !current.is_empty() {
549 tokens.push(current);
550 }
551 tokens
552}
553
554pub fn shell_quote(s: &str) -> String {
556 if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
557 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
558 } else {
559 s.to_string()
560 }
561}
562
563fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
564 let mut n: Option<usize> = None;
565 let mut path: Option<&str> = None;
566
567 let mut i = 0;
568 while i < args.len() {
569 if args[i] == "-n" && i + 1 < args.len() {
570 n = args[i + 1].parse().ok();
571 i += 2;
572 } else if let Some(num) = args[i].strip_prefix("-n") {
573 n = num.parse().ok();
574 i += 1;
575 } else if args[i].starts_with('-') && args[i].len() > 1 {
576 if let Ok(num) = args[i][1..].parse::<usize>() {
577 n = Some(num);
578 }
579 i += 1;
580 } else {
581 path = Some(args[i]);
582 i += 1;
583 }
584 }
585
586 (n, path)
587}
588
589fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
590 compound_lexer::rewrite_compound(cmd, |segment| {
591 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
592 return None;
593 }
594 if is_rewritable(segment) {
595 Some(wrap_single_command(segment, binary))
596 } else {
597 None
598 }
599 })
600}
601
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
604enum RedirectKind {
605 Read,
606 Grep,
607 Glob,
608 None,
609}
610
611fn classify_redirect(tool_name: &str) -> RedirectKind {
618 match tool_name {
619 "Read" | "read" | "read_file" | "view" => RedirectKind::Read,
620 "Grep" | "grep" | "search" | "ripgrep" | "rg" => RedirectKind::Grep,
621 "Glob" | "glob" => RedirectKind::Glob,
622 _ => RedirectKind::None,
623 }
624}
625
626pub fn handle_redirect() {
627 let allow = build_dual_allow_output();
628 if is_disabled() {
629 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
630 print!("{allow}");
631 return;
632 }
633
634 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
635 print!("{allow}");
636 return;
637 };
638
639 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
640 tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
641 print!("{allow}");
642 return;
643 };
644
645 let tool_name = payload::resolve_tool_name(&v).unwrap_or_default();
647 let tool_args = payload::resolve_tool_args(&v);
648
649 match classify_redirect(&tool_name) {
650 RedirectKind::Read => redirect_read(tool_args.as_ref()),
651 RedirectKind::Grep => redirect_grep(tool_args.as_ref()),
652 RedirectKind::Glob => redirect_glob(tool_args.as_ref()),
653 RedirectKind::None => print!("{allow}"),
654 }
655}
656
657fn redirect_read(tool_input: Option<&serde_json::Value>) {
661 let path = tool_input
662 .and_then(|ti| ti.get("path"))
663 .and_then(|p| p.as_str())
664 .unwrap_or("");
665
666 if path.is_empty() {
667 debug_log::log_hook_decision(
668 "redirect",
669 "Read",
670 Route::Native,
671 "<none>",
672 "no path in tool input",
673 );
674 print!("{}", build_dual_allow_output());
675 return;
676 }
677 if should_passthrough(path) {
678 debug_log::log_hook_decision(
679 "redirect",
680 "Read",
681 Route::Native,
682 path,
683 "passthrough path (sensitive/binary/excluded)",
684 );
685 print!("{}", build_dual_allow_output());
686 return;
687 }
688
689 let shadow = is_shadow_mode_active();
690 if is_harden_active() || shadow {
691 tracing::info!(
692 "[hook redirect] {} active, redirecting Read through lean-ctx",
693 if shadow { "shadow mode" } else { "harden mode" }
694 );
695 }
696
697 let binary = resolve_binary();
698 let temp_path = redirect_temp_path(path);
699
700 if let Some(mut output) =
701 run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT)
702 {
703 if shadow {
704 let header = format!(
705 "[shadow-mode: Read intercepted → ctx_read(\"{path}\", \"full\"). Use ctx_read directly for better performance.]\n\n"
706 );
707 let mut prefixed = header.into_bytes();
708 prefixed.append(&mut output);
709 output = prefixed;
710 }
711 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
712 let temp_str = temp_path.to_str().unwrap_or("");
713 debug_log::log_hook_decision(
714 "redirect",
715 "Read",
716 Route::LeanCtx,
717 path,
718 "redirected to ctx_read",
719 );
720 print!("{}", build_redirect_output(tool_input, "path", temp_str));
721 log_shadow_intercept("Read", path);
722 return;
723 }
724 }
725
726 debug_log::log_hook_decision(
727 "redirect",
728 "Read",
729 Route::Native,
730 path,
731 "lean-ctx read produced no output",
732 );
733 print!("{}", build_dual_allow_output());
734}
735
736fn grep_content_mode(tool_input: Option<&serde_json::Value>) -> bool {
745 tool_input
746 .and_then(|ti| ti.get("output_mode"))
747 .and_then(|m| m.as_str())
748 == Some("content")
749}
750
751fn redirect_grep(tool_input: Option<&serde_json::Value>) {
752 let pattern = tool_input
753 .and_then(|ti| ti.get("pattern"))
754 .and_then(|p| p.as_str())
755 .unwrap_or("");
756 let search_path = tool_input
757 .and_then(|ti| ti.get("path"))
758 .and_then(|p| p.as_str())
759 .unwrap_or(".");
760
761 if pattern.is_empty() {
762 debug_log::log_hook_decision(
763 "redirect",
764 "Grep",
765 Route::Native,
766 "<none>",
767 "no pattern in tool input",
768 );
769 print!("{}", build_dual_allow_output());
770 return;
771 }
772
773 if !grep_content_mode(tool_input) {
774 debug_log::log_hook_decision(
775 "redirect",
776 "Grep",
777 Route::Native,
778 &format!("{pattern} in {search_path}"),
779 "non-content output_mode — native passthrough (path-swap only valid for content)",
780 );
781 if is_shadow_mode_active() {
782 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
783 }
784 print!("{}", build_dual_allow_output());
785 return;
786 }
787
788 let shadow = is_shadow_mode_active();
789 if is_harden_active() || shadow {
790 tracing::info!(
791 "[hook redirect] {} active, redirecting Grep through lean-ctx",
792 if shadow { "shadow mode" } else { "harden mode" }
793 );
794 }
795
796 let binary = resolve_binary();
797 let key = format!("grep:{pattern}:{search_path}");
798 let temp_path = redirect_temp_path(&key);
799
800 if let Some(mut output) = run_with_timeout(
801 &binary,
802 &["grep", pattern, search_path],
803 REDIRECT_SUBPROCESS_TIMEOUT,
804 ) {
805 if shadow {
806 let header = format!(
807 "[shadow-mode: Grep intercepted → ctx_search(\"{pattern}\", \"{search_path}\"). Use ctx_search directly for better performance.]\n\n"
808 );
809 let mut prefixed = header.into_bytes();
810 prefixed.append(&mut output);
811 output = prefixed;
812 }
813 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
814 let temp_str = temp_path.to_str().unwrap_or("");
815 debug_log::log_hook_decision(
816 "redirect",
817 "Grep",
818 Route::LeanCtx,
819 &format!("{pattern} in {search_path}"),
820 "redirected to ctx_search",
821 );
822 print!("{}", build_redirect_output(tool_input, "path", temp_str));
823 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
824 return;
825 }
826 }
827
828 debug_log::log_hook_decision(
829 "redirect",
830 "Grep",
831 Route::Native,
832 &format!("{pattern} in {search_path}"),
833 "lean-ctx grep produced no output",
834 );
835 print!("{}", build_dual_allow_output());
836}
837
838fn redirect_glob(tool_input: Option<&serde_json::Value>) {
848 let allow = build_dual_allow_output();
849 let shadow = is_shadow_mode_active();
850 if !shadow && !is_harden_active() {
851 print!("{allow}");
852 return;
853 }
854
855 let pattern = tool_input
856 .and_then(|ti| ti.get("pattern"))
857 .and_then(|p| p.as_str())
858 .unwrap_or("");
859 if pattern.is_empty() {
860 debug_log::log_hook_decision(
861 "redirect",
862 "Glob",
863 Route::Native,
864 "<none>",
865 "no pattern in tool input",
866 );
867 print!("{allow}");
868 return;
869 }
870 let search_path = tool_input
871 .and_then(|ti| ti.get("path"))
872 .and_then(|p| p.as_str())
873 .unwrap_or(".");
874
875 tracing::info!(
876 "[hook redirect] {} active, warming ctx_glob for {pattern}",
877 if shadow { "shadow mode" } else { "harden mode" }
878 );
879
880 let binary = resolve_binary();
883 let _ = run_with_timeout(
884 &binary,
885 &["glob", pattern, search_path],
886 REDIRECT_SUBPROCESS_TIMEOUT,
887 );
888
889 debug_log::log_hook_decision(
890 "redirect",
891 "Glob",
892 Route::Native,
893 &format!("{pattern} in {search_path}"),
894 "shadow/harden warm — native passthrough",
895 );
896 log_shadow_intercept("Glob", &format!("{pattern} in {search_path}"));
897 print!("{allow}");
898}
899
900const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
901
902fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
905 let mut child = std::process::Command::new(binary)
906 .args(args)
907 .stdout(std::process::Stdio::piped())
908 .stderr(std::process::Stdio::null())
909 .spawn()
910 .ok()?;
911
912 let deadline = std::time::Instant::now() + timeout;
913 loop {
914 match child.try_wait() {
915 Ok(Some(status)) if status.success() => {
916 let mut stdout = Vec::new();
917 if let Some(mut out) = child.stdout.take() {
918 let _ = out.read_to_end(&mut stdout);
919 }
920 return if stdout.is_empty() {
921 None
922 } else {
923 Some(stdout)
924 };
925 }
926 Ok(Some(_)) | Err(_) => return None,
927 Ok(None) => {
928 if std::time::Instant::now() > deadline {
929 let _ = child.kill();
930 let _ = child.wait();
931 return None;
932 }
933 std::thread::sleep(Duration::from_millis(10));
934 }
935 }
936 }
937}
938
939fn redirect_temp_path(key: &str) -> std::path::PathBuf {
940 use std::collections::hash_map::DefaultHasher;
941 use std::hash::{Hash, Hasher};
942
943 let mut hasher = DefaultHasher::new();
944 key.hash(&mut hasher);
945 std::process::id().hash(&mut hasher);
946 let hash = hasher.finish();
947
948 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
949 let _ = std::fs::create_dir_all(&temp_dir);
950 #[cfg(unix)]
951 {
952 use std::os::unix::fs::PermissionsExt;
953 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
954 }
955 temp_dir.join(format!("{hash:016x}.lctx"))
956}
957
958fn build_redirect_output(
959 tool_input: Option<&serde_json::Value>,
960 field: &str,
961 temp_path: &str,
962) -> String {
963 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
964 let mut m = obj.clone();
965 m.insert(
966 field.to_string(),
967 serde_json::Value::String(temp_path.to_string()),
968 );
969 serde_json::Value::Object(m)
970 } else {
971 serde_json::json!({ field: temp_path })
972 };
973
974 serde_json::json!({
975 "permission": "allow",
977 "updated_input": updated_input.clone(),
978 "permissionDecision": "allow",
982 "modifiedArgs": updated_input.clone(),
983 "hookSpecificOutput": {
985 "hookEventName": "PreToolUse",
986 "permissionDecision": "allow",
987 "updatedInput": updated_input
988 }
989 })
990 .to_string()
991}
992
993const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
994 ".cursorrules",
995 ".cursor/rules",
996 ".cursor/hooks",
997 "skill.md",
998 "agents.md",
999 ".env",
1000 "hooks.json",
1001 "node_modules",
1002];
1003
1004const PASSTHROUGH_EXTENSIONS: &[&str] = &[
1005 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
1006];
1007
1008fn should_passthrough(path: &str) -> bool {
1009 let p = path.to_lowercase();
1010
1011 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
1012 return true;
1013 }
1014
1015 std::path::Path::new(&p)
1016 .extension()
1017 .and_then(|ext| ext.to_str())
1018 .is_some_and(|ext| {
1019 PASSTHROUGH_EXTENSIONS
1020 .iter()
1021 .any(|e| ext.eq_ignore_ascii_case(e))
1022 })
1023}
1024
1025fn codex_rewrite_output(rewritten: &str) -> String {
1026 serde_json::json!({
1027 "hookSpecificOutput": {
1028 "hookEventName": "PreToolUse",
1029 "permissionDecision": "allow",
1030 "updatedInput": {
1031 "command": rewritten
1032 }
1033 }
1034 })
1035 .to_string()
1036}
1037
1038pub fn handle_codex_pretooluse() {
1039 if is_disabled() {
1040 return;
1041 }
1042 let binary = resolve_binary();
1043 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1044 return;
1045 };
1046
1047 let tool = extract_json_field(&input, "tool_name");
1048 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1049 return;
1050 }
1051
1052 let Some(cmd) = extract_json_field(&input, "command") else {
1053 return;
1054 };
1055
1056 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1057 print!("{}", codex_rewrite_output(&rewritten));
1058 }
1059}
1060
1061pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
1071 serde_json::json!({
1072 "hookSpecificOutput": {
1073 "hookEventName": "SessionStart",
1074 "additionalContext": additional_context,
1075 }
1076 })
1077 .to_string()
1078}
1079
1080pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
1081 println!(
1082 "{}",
1083 session_start_additional_context_json(additional_context)
1084 );
1085}
1086
1087pub fn handle_codex_session_start() {
1088 if is_quiet() {
1089 return;
1090 }
1091 if crate::core::config::Config::load().dedicated_session_context_active() {
1095 return;
1096 }
1097 emit_session_start_additional_context(
1098 "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.",
1099 );
1100}
1101
1102pub fn handle_copilot() {
1111 if is_disabled() {
1112 return;
1113 }
1114 let binary = resolve_binary();
1115 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1116 return;
1117 };
1118
1119 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
1120 return;
1121 };
1122
1123 let Some(tool_name) = payload::resolve_tool_name(&v) else {
1124 return;
1125 };
1126
1127 if !is_shell_tool(&tool_name) {
1128 return;
1129 }
1130
1131 let tool_args = payload::resolve_tool_args(&v);
1132 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
1133 return;
1134 };
1135
1136 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1137 print!(
1138 "{}",
1139 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
1140 );
1141 }
1142}
1143
1144pub fn handle_rewrite_inline() {
1147 if is_disabled() {
1148 return;
1149 }
1150 let binary = resolve_binary();
1151 let args: Vec<String> = std::env::args().collect();
1152 if args.len() < 4 {
1154 return;
1155 }
1156 let cmd = args[3..].join(" ");
1157
1158 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1159 print!("{rewritten}");
1160 return;
1161 }
1162
1163 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1164 print!("{cmd}");
1165 return;
1166 }
1167
1168 print!("{cmd}");
1169}
1170
1171fn resolve_binary() -> String {
1178 crate::core::portable_binary::resolve_portable_binary()
1179}
1180
1181fn extract_json_field(input: &str, field: &str) -> Option<String> {
1182 let key = format!("\"{field}\":");
1183 let key_pos = input.find(&key)?;
1184 let after_colon = &input[key_pos + key.len()..];
1185 let trimmed = after_colon.trim_start();
1186 if !trimmed.starts_with('"') {
1187 return None;
1188 }
1189 let rest = &trimmed[1..];
1190 let bytes = rest.as_bytes();
1191 let mut end = 0;
1192 while end < bytes.len() {
1193 if bytes[end] == b'\\' && end + 1 < bytes.len() {
1194 end += 2;
1195 continue;
1196 }
1197 if bytes[end] == b'"' {
1198 break;
1199 }
1200 end += 1;
1201 }
1202 if end >= bytes.len() {
1203 return None;
1204 }
1205 let raw = &rest[..end];
1206 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1207}