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;
20mod read_dedup;
21pub use observe::*;
22pub use read_dedup::handle_read_dedup;
23#[cfg(test)]
24mod tests;
25
26fn is_disabled() -> bool {
27 std::env::var("LEAN_CTX_DISABLED").is_ok()
28}
29
30fn is_harden_active() -> bool {
31 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
32}
33
34fn is_shadow_mode_active() -> bool {
35 if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
36 return true;
37 }
38 crate::core::config::Config::load().shadow_mode
39}
40
41fn log_shadow_intercept(tool: &str, detail: &str) {
42 if !is_shadow_mode_active() {
43 return;
44 }
45 let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
46 return;
47 };
48 let log_path = data_dir.join("shadow.log");
49 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
50 let line = format!("[{ts}] intercepted {tool}: {detail}\n");
51 let _ = std::fs::OpenOptions::new()
52 .create(true)
53 .append(true)
54 .open(log_path)
55 .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
56}
57
58fn is_quiet() -> bool {
59 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
60}
61
62pub fn mark_hook_environment() {
65 unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
68}
69
70pub fn arm_watchdog(timeout: Duration) {
75 std::thread::spawn(move || {
76 std::thread::sleep(timeout);
77 eprintln!(
78 "[lean-ctx hook] watchdog timeout after {}s — force exit",
79 timeout.as_secs()
80 );
81 std::process::exit(1);
82 });
83}
84
85fn emit_gating_decision<F>(timeout: Duration, work: F)
95where
96 F: FnOnce() -> String + Send + 'static,
97{
98 let out = decide_with_timeout(timeout, build_dual_allow_output(), work);
99 print!("{out}");
100}
101
102fn decide_with_timeout<F>(timeout: Duration, fallback: String, work: F) -> String
108where
109 F: FnOnce() -> String + Send + 'static,
110{
111 let (tx, rx) = mpsc::channel();
112 std::thread::spawn(move || {
113 let _ = tx.send(work());
114 });
115 rx.recv_timeout(timeout).unwrap_or(fallback)
116}
117
118fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
120 let (tx, rx) = mpsc::channel();
121 std::thread::spawn(move || {
122 let mut buf = String::new();
123 let result = std::io::stdin().read_to_string(&mut buf);
124 let _ = tx.send(result.ok().map(|_| buf));
125 });
126 match rx.recv_timeout(timeout) {
127 Ok(Some(s)) if !s.is_empty() => Some(s),
128 _ => None,
129 }
130}
131
132fn build_dual_allow_output() -> String {
133 serde_json::json!({
134 "permission": "allow",
135 "hookSpecificOutput": {
136 "hookEventName": "PreToolUse",
137 "permissionDecision": "allow"
138 }
139 })
140 .to_string()
141}
142
143fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
144 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
145 let mut m = obj.clone();
146 m.insert(
147 "command".to_string(),
148 serde_json::Value::String(rewritten.to_string()),
149 );
150 serde_json::Value::Object(m)
151 } else {
152 serde_json::json!({ "command": rewritten })
153 };
154
155 serde_json::json!({
156 "permission": "allow",
158 "updated_input": updated_input.clone(),
159 "permissionDecision": "allow",
164 "modifiedArgs": updated_input.clone(),
165 "hookSpecificOutput": {
167 "hookEventName": "PreToolUse",
168 "permissionDecision": "allow",
169 "updatedInput": updated_input
170 }
171 })
172 .to_string()
173}
174
175fn is_shell_tool(tool_name: &str) -> bool {
181 matches!(
182 tool_name,
183 "Bash"
184 | "bash"
185 | "Shell"
186 | "shell"
187 | "runInTerminal"
188 | "run_in_terminal"
189 | "terminal"
190 | "PowerShell"
191 | "powershell"
192 | "pwsh"
193 )
194}
195
196pub fn handle_rewrite() {
197 emit_gating_decision(HOOK_GATING_TIMEOUT, compute_rewrite);
198}
199
200fn compute_rewrite() -> String {
203 if is_disabled() {
204 return build_dual_allow_output();
205 }
206 let binary = resolve_binary();
207 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
208 return build_dual_allow_output();
209 };
210
211 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
212 tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
213 return build_dual_allow_output();
214 };
215
216 let Some(tool_name) = payload::resolve_tool_name(&v) else {
220 return build_dual_allow_output();
221 };
222
223 if !is_shell_tool(&tool_name) {
224 return build_dual_allow_output();
225 }
226
227 let tool_args = payload::resolve_tool_args(&v);
228 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
229 return build_dual_allow_output();
230 };
231
232 let key_material = format!("{tool_name}\u{0}{cmd}");
235 dedup::deduped("rewrite", &key_material, || {
236 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
237 debug_log::log_hook_decision(
238 "rewrite",
239 &tool_name,
240 Route::LeanCtx,
241 &cmd,
242 "rewritable command",
243 );
244 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
245 } else {
246 debug_log::log_hook_decision(
247 "rewrite",
248 &tool_name,
249 Route::Native,
250 &cmd,
251 rewrite_skip_reason(&cmd),
252 );
253 build_dual_allow_output()
254 }
255 })
256}
257
258fn rewrite_skip_reason(cmd: &str) -> &'static str {
262 if cmd.starts_with("lean-ctx ") {
263 "already a lean-ctx command"
264 } else if cmd.contains("<<") {
265 "heredoc cannot be rewritten safely"
266 } else if is_compound(cmd) && !crate::core::shell_allowlist::passes_enforced(cmd) {
267 "compound pipes/chains into a non-allowlisted or interpreter sink — left raw for the agent shell"
268 } else {
269 "not a known read/search/list command"
270 }
271}
272
273fn is_rewritable(cmd: &str) -> bool {
274 rewrite_registry::is_rewritable_command(cmd)
275}
276
277fn is_compound(cmd: &str) -> bool {
283 compound_lexer::split_compound(cmd)
284 .iter()
285 .any(|s| matches!(s, compound_lexer::Segment::Operator(_)))
286}
287
288fn wrap_single_command(cmd: &str, binary: &str) -> String {
289 if cfg!(windows) {
290 let escaped = cmd.replace('"', "\\\"");
291 format!("{binary} -c \"{escaped}\"")
292 } else {
293 let shell_escaped = cmd.replace('\'', "'\\''");
294 format!("{binary} -c '{shell_escaped}'")
295 }
296}
297
298fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
299 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
300 return None;
301 }
302
303 if cmd.contains("<<") {
306 return None;
307 }
308
309 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
310 return Some(rewritten);
311 }
312
313 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
314 return Some(rewritten);
315 }
316
317 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
318 return Some(rewritten);
319 }
320
321 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
322 return Some(rewritten);
323 }
324
325 if !is_compound(cmd) && is_rewritable(cmd) {
331 return Some(wrap_single_command(cmd, binary));
332 }
333
334 None
335}
336
337fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
340 if !rewrite_registry::is_file_read_command(cmd) && !is_powershell_file_read(cmd) {
344 return None;
345 }
346
347 if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
349 return None;
350 }
351
352 if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
354 return None;
355 }
356
357 let parts = shell_tokenize(cmd);
358 if parts.len() < 2 {
359 return None;
360 }
361
362 match parts[0].as_str() {
363 "cat" => {
364 let path = parts[1..].join(" ");
365 if is_outside_project_path(&path) {
366 return None;
367 }
368 Some(format!("{binary} read {}", shell_quote(&path)))
369 }
370 "head" => {
371 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
372 let (n, path) = parse_head_tail_args(&refs);
373 let path = path?;
374 if is_outside_project_path(path) {
375 return None;
376 }
377 let qp = shell_quote(path);
378 match n {
379 Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
380 None => Some(format!("{binary} read {qp} -m lines:1-10")),
381 }
382 }
383 "tail" => {
384 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
385 let (n, path) = parse_head_tail_args(&refs);
386 let path = path?;
387 if is_outside_project_path(path) {
388 return None;
389 }
390 let qp = shell_quote(path);
391 let lines = n.unwrap_or(10);
392 Some(format!("{binary} read {qp} -m lines:-{lines}"))
393 }
394 "Get-Content" | "gc" => rewrite_get_content(&parts, binary),
395 _ => None,
396 }
397}
398
399fn is_powershell_file_read(cmd: &str) -> bool {
401 matches!(cmd.split_whitespace().next(), Some("Get-Content" | "gc"))
402}
403
404fn rewrite_get_content(parts: &[String], binary: &str) -> Option<String> {
410 let mut path: Option<String> = None;
411 let mut head_n: Option<u64> = None;
412 let mut tail_n: Option<u64> = None;
413 let mut i = 1;
414 while i < parts.len() {
415 if let Some(flag) = parts[i].strip_prefix('-') {
416 let value = parts.get(i + 1);
417 match flag.to_ascii_lowercase().as_str() {
418 "path" | "literalpath" => path = Some(value?.clone()),
419 "totalcount" | "head" | "first" => head_n = Some(value?.parse().ok()?),
420 "tail" | "last" => tail_n = Some(value?.parse().ok()?),
421 _ => return None,
422 }
423 i += 2;
424 } else if path.is_none() {
425 path = Some(parts[i].clone());
426 i += 1;
427 } else {
428 return None;
429 }
430 }
431 let path = path?;
432 if is_outside_project_path(&path) || (head_n.is_some() && tail_n.is_some()) {
433 return None;
434 }
435 let qp = shell_quote(&path);
436 match (head_n, tail_n) {
437 (Some(n), None) => Some(format!("{binary} read {qp} -m lines:1-{n}")),
438 (None, Some(n)) => Some(format!("{binary} read {qp} -m lines:-{n}")),
439 _ => Some(format!("{binary} read {qp}")),
440 }
441}
442
443fn is_outside_project_path(path: &str) -> bool {
447 let trimmed = path.trim();
448
449 if trimmed.starts_with('~') {
451 return true;
452 }
453
454 if trimmed.starts_with('$') {
456 return true;
457 }
458
459 if trimmed.starts_with("/proc/")
461 || trimmed.starts_with("/sys/")
462 || trimmed.starts_with("/dev/")
463 || trimmed.starts_with("/tmp/")
464 || trimmed.starts_with("/var/")
465 {
466 return true;
467 }
468
469 if trimmed.starts_with('/') {
473 if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
475 return true;
476 }
477 if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
479 return true;
480 }
481 }
482
483 false
484}
485
486fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
489 let parts = shell_tokenize(cmd);
490 match parts.first().map(String::as_str) {
491 Some("rg") => {
492 if parts.len() < 2 || parts.len() > 3 || parts[1].starts_with('-') {
493 return None;
494 }
495 let pattern = &parts[1];
496 match parts.get(2) {
497 Some(p) if p.starts_with('-') => None,
498 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
499 None => Some(format!("{binary} grep {pattern}")),
500 }
501 }
502 Some("Select-String" | "sls") => rewrite_select_string(&parts, binary),
503 _ => None,
504 }
505}
506
507fn rewrite_select_string(parts: &[String], binary: &str) -> Option<String> {
512 let mut pattern: Option<String> = None;
513 let mut path: Option<String> = None;
514 let mut i = 1;
515 while i < parts.len() {
516 if let Some(flag) = parts[i].strip_prefix('-') {
517 let value = parts.get(i + 1);
518 match flag.to_ascii_lowercase().as_str() {
519 "pattern" => pattern = Some(value?.clone()),
520 "path" | "literalpath" => path = Some(value?.clone()),
521 _ => return None,
522 }
523 i += 2;
524 } else if pattern.is_none() {
525 pattern = Some(parts[i].clone());
526 i += 1;
527 } else if path.is_none() {
528 path = Some(parts[i].clone());
529 i += 1;
530 } else {
531 return None;
532 }
533 }
534 let pattern = shell_quote(&pattern?);
535 match path {
536 Some(p) if is_outside_project_path(&p) => None,
537 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(&p))),
538 None => Some(format!("{binary} grep {pattern}")),
539 }
540}
541
542fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
545 let parts = shell_tokenize(cmd);
546 match parts.first().map(String::as_str) {
547 Some("ls") => match parts.len() {
548 1 => Some(format!("{binary} ls")),
549 2 if !parts[1].starts_with('-') => {
550 Some(format!("{binary} ls {}", shell_quote(&parts[1])))
551 }
552 _ => None,
553 },
554 Some("Get-ChildItem" | "gci") => rewrite_get_childitem(&parts, binary),
555 _ => None,
556 }
557}
558
559fn rewrite_get_childitem(parts: &[String], binary: &str) -> Option<String> {
563 let mut path: Option<String> = None;
564 let mut i = 1;
565 while i < parts.len() {
566 if let Some(flag) = parts[i].strip_prefix('-') {
567 let value = parts.get(i + 1);
568 match flag.to_ascii_lowercase().as_str() {
569 "path" | "literalpath" => path = Some(value?.clone()),
570 _ => return None,
571 }
572 i += 2;
573 } else if path.is_none() {
574 path = Some(parts[i].clone());
575 i += 1;
576 } else {
577 return None;
578 }
579 }
580 match path {
581 Some(p) => Some(format!("{binary} ls {}", shell_quote(&p))),
582 None => Some(format!("{binary} ls")),
583 }
584}
585
586pub fn shell_tokenize(input: &str) -> Vec<String> {
588 let mut tokens = Vec::new();
589 let mut current = String::new();
590 let mut chars = input.chars().peekable();
591 let mut in_single = false;
592 let mut in_double = false;
593
594 while let Some(c) = chars.next() {
595 match c {
596 '\'' if !in_double => in_single = !in_single,
597 '"' if !in_single => in_double = !in_double,
598 '\\' if !in_single => {
599 if let Some(next) = chars.next() {
600 current.push(next);
601 }
602 }
603 c if c.is_whitespace() && !in_single && !in_double => {
604 if !current.is_empty() {
605 tokens.push(std::mem::take(&mut current));
606 }
607 }
608 _ => current.push(c),
609 }
610 }
611 if !current.is_empty() {
612 tokens.push(current);
613 }
614 tokens
615}
616
617pub fn shell_quote(s: &str) -> String {
619 if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
620 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
621 } else {
622 s.to_string()
623 }
624}
625
626fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
627 let mut n: Option<usize> = None;
628 let mut path: Option<&str> = None;
629
630 let mut i = 0;
631 while i < args.len() {
632 if args[i] == "-n" && i + 1 < args.len() {
633 n = args[i + 1].parse().ok();
634 i += 2;
635 } else if let Some(num) = args[i].strip_prefix("-n") {
636 n = num.parse().ok();
637 i += 1;
638 } else if args[i].starts_with('-') && args[i].len() > 1 {
639 if let Ok(num) = args[i][1..].parse::<usize>() {
640 n = Some(num);
641 }
642 i += 1;
643 } else {
644 path = Some(args[i]);
645 i += 1;
646 }
647 }
648
649 (n, path)
650}
651
652fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
676 let segments = compound_lexer::split_compound(cmd);
677 let commands: Vec<&str> = segments
678 .iter()
679 .filter_map(|s| match s {
680 compound_lexer::Segment::Command(c) => Some(c.trim()),
681 compound_lexer::Segment::Operator(_) => None,
682 })
683 .collect();
684
685 if segments.len() == commands.len() {
688 return None;
689 }
690
691 let is_leanctx = |c: &str| c.starts_with("lean-ctx ") || c.starts_with(&format!("{binary} "));
692
693 if commands.iter().any(|c| is_leanctx(c)) {
695 return None;
696 }
697
698 if !commands.iter().any(|c| is_rewritable(c)) {
700 return None;
701 }
702
703 if crate::core::shell_allowlist::passes_enforced(cmd) {
706 Some(wrap_single_command(cmd, binary))
707 } else {
708 None
709 }
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714enum RedirectKind {
715 Read,
716 Grep,
717 Glob,
718 None,
719}
720
721fn classify_redirect(tool_name: &str) -> RedirectKind {
728 match tool_name {
729 "Read" | "read" | "read_file" | "view" => RedirectKind::Read,
730 "Grep" | "grep" | "search" | "ripgrep" | "rg" => RedirectKind::Grep,
731 "Glob" | "glob" => RedirectKind::Glob,
732 _ => RedirectKind::None,
733 }
734}
735
736pub fn handle_redirect() {
737 emit_gating_decision(HOOK_GATING_TIMEOUT, compute_redirect);
738}
739
740fn compute_redirect() -> String {
743 if is_disabled() {
744 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
745 return build_dual_allow_output();
746 }
747
748 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
749 return build_dual_allow_output();
750 };
751
752 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
753 tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
754 return build_dual_allow_output();
755 };
756
757 let tool_name = payload::resolve_tool_name(&v).unwrap_or_default();
759 let tool_args = payload::resolve_tool_args(&v);
760
761 let kind = classify_redirect(&tool_name);
762 if matches!(kind, RedirectKind::None) {
763 return build_dual_allow_output();
764 }
765
766 let args_json = tool_args
771 .as_ref()
772 .map(ToString::to_string)
773 .unwrap_or_default();
774 let key_material = format!("{tool_name}\u{0}{args_json}");
775 dedup::deduped("redirect", &key_material, || {
776 produce_redirect_output(kind, tool_args.as_ref())
777 })
778}
779
780fn produce_redirect_output(kind: RedirectKind, tool_args: Option<&serde_json::Value>) -> String {
784 match kind {
785 RedirectKind::Read => redirect_read(tool_args),
786 RedirectKind::Grep => redirect_grep(tool_args),
787 RedirectKind::Glob => redirect_glob(tool_args),
788 RedirectKind::None => build_dual_allow_output(),
789 }
790}
791
792fn redirect_read_args(path: &str) -> [&str; 4] {
801 ["read", path, "-m", "full"]
802}
803
804fn redirect_read(tool_input: Option<&serde_json::Value>) -> String {
808 let Some((path_field, path)) =
812 payload::resolve_path_field(tool_input, payload::READ_PATH_FIELDS)
813 else {
814 debug_log::log_hook_decision(
815 "redirect",
816 "Read",
817 Route::Native,
818 "<none>",
819 "no path in tool input",
820 );
821 return build_dual_allow_output();
822 };
823 if !crate::core::config::ReadRedirect::read_redirect_enabled(
832 &crate::core::config::Config::load(),
833 ) {
834 debug_log::log_hook_decision(
835 "redirect",
836 "Read",
837 Route::Native,
838 &path,
839 "read redirect disabled (host guard/config)",
840 );
841 return build_dual_allow_output();
842 }
843 if should_passthrough(&path) {
844 debug_log::log_hook_decision(
845 "redirect",
846 "Read",
847 Route::Native,
848 &path,
849 "passthrough path (sensitive/binary/excluded)",
850 );
851 return build_dual_allow_output();
852 }
853
854 let shadow = is_shadow_mode_active();
855 if is_harden_active() || shadow {
856 tracing::info!(
857 "[hook redirect] {} active, redirecting Read through lean-ctx",
858 if shadow { "shadow mode" } else { "harden mode" }
859 );
860 }
861
862 let binary = resolve_binary();
863 let temp_path = redirect_temp_path(&path);
864
865 if let Some(output) = run_with_timeout(
866 &binary,
867 &redirect_read_args(&path),
868 REDIRECT_SUBPROCESS_TIMEOUT,
869 ) {
870 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
876 let temp_str = temp_path.to_str().unwrap_or("");
877 debug_log::log_hook_decision(
878 "redirect",
879 "Read",
880 Route::LeanCtx,
881 &path,
882 "redirected to ctx_read",
883 );
884 let shadow_note = shadow.then(|| {
885 format!(
886 "lean-ctx shadow mode: this Read was served by ctx_read(\"{path}\", \"full\"). Call ctx_read directly for better performance."
887 )
888 });
889 log_shadow_intercept("Read", &path);
890 return build_redirect_output(tool_input, path_field, temp_str, shadow_note.as_deref());
891 }
892 }
893
894 debug_log::log_hook_decision(
895 "redirect",
896 "Read",
897 Route::Native,
898 &path,
899 "lean-ctx read produced no output",
900 );
901 build_dual_allow_output()
902}
903
904fn grep_content_mode(tool_input: Option<&serde_json::Value>) -> bool {
913 tool_input
914 .and_then(|ti| ti.get("output_mode"))
915 .and_then(|m| m.as_str())
916 == Some("content")
917}
918
919fn redirect_grep(tool_input: Option<&serde_json::Value>) -> String {
920 let pattern = tool_input
921 .and_then(|ti| ti.get("pattern"))
922 .and_then(|p| p.as_str())
923 .unwrap_or("");
924 let search_path = tool_input
925 .and_then(|ti| ti.get("path"))
926 .and_then(|p| p.as_str())
927 .unwrap_or(".");
928
929 if pattern.is_empty() {
930 debug_log::log_hook_decision(
931 "redirect",
932 "Grep",
933 Route::Native,
934 "<none>",
935 "no pattern in tool input",
936 );
937 return build_dual_allow_output();
938 }
939
940 if !grep_content_mode(tool_input) {
941 debug_log::log_hook_decision(
942 "redirect",
943 "Grep",
944 Route::Native,
945 &format!("{pattern} in {search_path}"),
946 "non-content output_mode — native passthrough (path-swap only valid for content)",
947 );
948 if is_shadow_mode_active() {
949 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
950 }
951 return build_dual_allow_output();
952 }
953
954 let shadow = is_shadow_mode_active();
955 if is_harden_active() || shadow {
956 tracing::info!(
957 "[hook redirect] {} active, redirecting Grep through lean-ctx",
958 if shadow { "shadow mode" } else { "harden mode" }
959 );
960 }
961
962 let binary = resolve_binary();
963 let key = format!("grep:{pattern}:{search_path}");
964 let temp_path = redirect_temp_path(&key);
965
966 if let Some(output) = run_with_timeout(
967 &binary,
968 &["grep", pattern, search_path],
969 REDIRECT_SUBPROCESS_TIMEOUT,
970 ) {
971 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
975 let temp_str = temp_path.to_str().unwrap_or("");
976 debug_log::log_hook_decision(
977 "redirect",
978 "Grep",
979 Route::LeanCtx,
980 &format!("{pattern} in {search_path}"),
981 "redirected to ctx_search",
982 );
983 let shadow_note = shadow.then(|| {
984 format!(
985 "lean-ctx shadow mode: this Grep was served by ctx_search(\"{pattern}\", \"{search_path}\"). Call ctx_search directly for better performance."
986 )
987 });
988 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
989 return build_redirect_output(tool_input, "path", temp_str, shadow_note.as_deref());
990 }
991 }
992
993 debug_log::log_hook_decision(
994 "redirect",
995 "Grep",
996 Route::Native,
997 &format!("{pattern} in {search_path}"),
998 "lean-ctx grep produced no output",
999 );
1000 build_dual_allow_output()
1001}
1002
1003fn redirect_glob(tool_input: Option<&serde_json::Value>) -> String {
1018 let allow = build_dual_allow_output();
1019 let shadow = is_shadow_mode_active();
1020 if !shadow && !is_harden_active() {
1021 return allow;
1022 }
1023
1024 let pattern = tool_input
1025 .and_then(|ti| ti.get("pattern"))
1026 .and_then(|p| p.as_str())
1027 .unwrap_or("");
1028 if pattern.is_empty() {
1029 debug_log::log_hook_decision(
1030 "redirect",
1031 "Glob",
1032 Route::Native,
1033 "<none>",
1034 "no pattern in tool input",
1035 );
1036 return allow;
1037 }
1038
1039 let search_path = tool_input
1040 .and_then(|ti| ti.get("path"))
1041 .and_then(|p| p.as_str())
1042 .unwrap_or(".");
1043
1044 tracing::info!(
1045 "[hook redirect] {} active, warming ctx_glob for {pattern}",
1046 if shadow { "shadow mode" } else { "harden mode" }
1047 );
1048
1049 let binary = resolve_binary();
1052 let _ = run_with_timeout(
1053 &binary,
1054 &["glob", pattern, search_path],
1055 REDIRECT_SUBPROCESS_TIMEOUT,
1056 );
1057
1058 debug_log::log_hook_decision(
1059 "redirect",
1060 "Glob",
1061 Route::Native,
1062 &format!("{pattern} in {search_path}"),
1063 "shadow/harden warm — native passthrough",
1064 );
1065 log_shadow_intercept("Glob", &format!("{pattern} in {search_path}"));
1066 allow
1067}
1068
1069const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
1070
1071fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
1074 let mut child = std::process::Command::new(binary)
1075 .args(args)
1076 .stdout(std::process::Stdio::piped())
1077 .stderr(std::process::Stdio::null())
1078 .spawn()
1079 .ok()?;
1080
1081 let deadline = std::time::Instant::now() + timeout;
1082 loop {
1083 match child.try_wait() {
1084 Ok(Some(status)) if status.success() => {
1085 let mut stdout = Vec::new();
1086 if let Some(mut out) = child.stdout.take() {
1087 let _ = out.read_to_end(&mut stdout);
1088 }
1089 return if stdout.is_empty() {
1090 None
1091 } else {
1092 Some(stdout)
1093 };
1094 }
1095 Ok(Some(_)) | Err(_) => return None,
1096 Ok(None) => {
1097 if std::time::Instant::now() > deadline {
1098 let _ = child.kill();
1099 let _ = child.wait();
1100 return None;
1101 }
1102 std::thread::sleep(Duration::from_millis(10));
1103 }
1104 }
1105 }
1106}
1107
1108fn redirect_temp_path(key: &str) -> std::path::PathBuf {
1109 use std::collections::hash_map::DefaultHasher;
1110 use std::hash::{Hash, Hasher};
1111
1112 let mut hasher = DefaultHasher::new();
1113 key.hash(&mut hasher);
1114 std::process::id().hash(&mut hasher);
1115 let hash = hasher.finish();
1116
1117 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
1118 let _ = std::fs::create_dir_all(&temp_dir);
1119 #[cfg(unix)]
1120 {
1121 use std::os::unix::fs::PermissionsExt;
1122 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
1123 }
1124 temp_dir.join(format!("{hash:016x}.lctx"))
1125}
1126
1127fn build_redirect_output(
1128 tool_input: Option<&serde_json::Value>,
1129 field: &str,
1130 temp_path: &str,
1131 shadow_note: Option<&str>,
1132) -> String {
1133 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
1134 let mut m = obj.clone();
1135 m.insert(
1136 field.to_string(),
1137 serde_json::Value::String(temp_path.to_string()),
1138 );
1139 serde_json::Value::Object(m)
1140 } else {
1141 serde_json::json!({ field: temp_path })
1142 };
1143
1144 let mut hook_specific = serde_json::json!({
1146 "hookEventName": "PreToolUse",
1147 "permissionDecision": "allow",
1148 "updatedInput": updated_input.clone(),
1149 });
1150 if let Some(note) = shadow_note {
1154 hook_specific["additionalContext"] = serde_json::Value::String(note.to_string());
1155 }
1156
1157 serde_json::json!({
1158 "permission": "allow",
1160 "updated_input": updated_input.clone(),
1161 "permissionDecision": "allow",
1165 "modifiedArgs": updated_input.clone(),
1166 "hookSpecificOutput": hook_specific
1167 })
1168 .to_string()
1169}
1170
1171const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
1172 ".cursorrules",
1173 ".cursor/rules",
1174 ".cursor/hooks",
1175 "skill.md",
1176 "agents.md",
1177 ".env",
1178 "hooks.json",
1179 "node_modules",
1180];
1181
1182const PASSTHROUGH_EXTENSIONS: &[&str] = &[
1183 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
1184];
1185
1186fn should_passthrough(path: &str) -> bool {
1187 let p = path.to_lowercase();
1188
1189 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
1190 return true;
1191 }
1192
1193 std::path::Path::new(&p)
1194 .extension()
1195 .and_then(|ext| ext.to_str())
1196 .is_some_and(|ext| {
1197 PASSTHROUGH_EXTENSIONS
1198 .iter()
1199 .any(|e| ext.eq_ignore_ascii_case(e))
1200 })
1201}
1202
1203fn codex_rewrite_output(rewritten: &str) -> String {
1204 serde_json::json!({
1205 "hookSpecificOutput": {
1206 "hookEventName": "PreToolUse",
1207 "permissionDecision": "allow",
1208 "updatedInput": {
1209 "command": rewritten
1210 }
1211 }
1212 })
1213 .to_string()
1214}
1215
1216pub fn handle_codex_pretooluse() {
1217 if is_disabled() {
1218 return;
1219 }
1220 let binary = resolve_binary();
1221 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1222 return;
1223 };
1224
1225 let tool = extract_json_field(&input, "tool_name");
1226 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1227 return;
1228 }
1229
1230 let Some(cmd) = extract_json_field(&input, "command") else {
1231 return;
1232 };
1233
1234 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1235 print!("{}", codex_rewrite_output(&rewritten));
1236 }
1237}
1238
1239pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
1249 serde_json::json!({
1250 "hookSpecificOutput": {
1251 "hookEventName": "SessionStart",
1252 "additionalContext": additional_context,
1253 }
1254 })
1255 .to_string()
1256}
1257
1258pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
1259 println!(
1260 "{}",
1261 session_start_additional_context_json(additional_context)
1262 );
1263}
1264
1265pub(crate) const CODEX_SHELL_RECOVERY_HINT: &str = r#"RAW OUTPUT RULE (shell)
1282
1283Compressed shell output is not exact evidence. When you need exact content
1284(file text, log lines, quotes, counts, line numbers), you MUST re-run the
1285command as `lean-ctx raw "<exact command>"` — never reconstruct it from the
1286compressed view with chunked reads (`cat`/`sed`/`head`/`tail`), and never quote
1287compressed output as if it were exact. If a Bash call is blocked, re-run the
1288exact command the hook suggests.
1289
1290Rule of thumb: back every exact claim with `lean-ctx raw` output."#;
1291pub fn handle_codex_session_start() {
1292 if is_quiet() {
1293 return;
1294 }
1295 if crate::core::config::Config::load().dedicated_session_context_active() {
1299 return;
1300 }
1301 emit_session_start_additional_context(CODEX_SHELL_RECOVERY_HINT);
1302}
1303
1304pub fn handle_copilot() {
1313 if is_disabled() {
1314 return;
1315 }
1316 let binary = resolve_binary();
1317 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1318 return;
1319 };
1320
1321 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
1322 return;
1323 };
1324
1325 let Some(tool_name) = payload::resolve_tool_name(&v) else {
1326 return;
1327 };
1328
1329 if !is_shell_tool(&tool_name) {
1330 return;
1331 }
1332
1333 let tool_args = payload::resolve_tool_args(&v);
1334 let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
1335 return;
1336 };
1337
1338 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1339 print!(
1340 "{}",
1341 build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
1342 );
1343 }
1344}
1345
1346pub fn handle_rewrite_inline() {
1349 if is_disabled() {
1350 return;
1351 }
1352 let binary = resolve_binary();
1353 let args: Vec<String> = std::env::args().collect();
1354 if args.len() < 4 {
1356 return;
1357 }
1358 let cmd = args[3..].join(" ");
1359
1360 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1361 print!("{rewritten}");
1362 return;
1363 }
1364
1365 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1366 print!("{cmd}");
1367 return;
1368 }
1369
1370 print!("{cmd}");
1371}
1372
1373fn resolve_binary() -> String {
1380 crate::core::portable_binary::resolve_portable_binary()
1381}
1382
1383fn extract_json_field(input: &str, field: &str) -> Option<String> {
1384 let key = format!("\"{field}\":");
1385 let key_pos = input.find(&key)?;
1386 let after_colon = &input[key_pos + key.len()..];
1387 let trimmed = after_colon.trim_start();
1388 if !trimmed.starts_with('"') {
1389 return None;
1390 }
1391 let rest = &trimmed[1..];
1392 let bytes = rest.as_bytes();
1393 let mut end = 0;
1394 while end < bytes.len() {
1395 if bytes[end] == b'\\' && end + 1 < bytes.len() {
1396 end += 2;
1397 continue;
1398 }
1399 if bytes[end] == b'"' {
1400 break;
1401 }
1402 end += 1;
1403 }
1404 if end >= bytes.len() {
1405 return None;
1406 }
1407 let raw = &rest[..end];
1408 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1409}