1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4use std::sync::mpsc;
5use std::time::Duration;
6
7const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
8
9pub fn handle_observe() {
17 if is_disabled() {
18 return;
19 }
20 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
21 return;
22 };
23 let Some(event) = parse_observe_event(&input) else {
24 return;
25 };
26 append_radar_event(&event);
27}
28
29#[derive(serde::Serialize)]
30struct ObserveEvent {
31 ts: u64,
32 event_type: &'static str,
33 tokens: usize,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 tool_name: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 detail: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 content: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 model: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 conversation_id: Option<String>,
44}
45
46const MAX_CONTENT_CHARS: usize = 50_000;
47
48fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
49 let v: serde_json::Value = serde_json::from_str(input).ok()?;
50
51 let ts = std::time::SystemTime::now()
52 .duration_since(std::time::UNIX_EPOCH)
53 .unwrap_or_default()
54 .as_secs();
55
56 let model = v
57 .get("model")
58 .and_then(|m| m.as_str())
59 .filter(|m| !m.is_empty())
60 .map(String::from);
61 let conversation_id = v
62 .get("conversation_id")
63 .and_then(|c| c.as_str())
64 .filter(|c| !c.is_empty())
65 .map(String::from);
66
67 let transcript_path = v
68 .get("transcript_path")
69 .and_then(|t| t.as_str())
70 .filter(|t| !t.is_empty())
71 .map(String::from);
72
73 if let Some(ref m) = model {
74 persist_detected_model(m);
75 }
76 if let Some(ref tp) = transcript_path {
77 persist_transcript_path(tp, conversation_id.as_deref());
78 }
79
80 let mut event = detect_event_type(&v, ts)?;
81 event.model = model;
82 event.conversation_id = conversation_id;
83 Some(event)
84}
85
86fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
87 if let Some(result) = v.get("result_json").or_else(|| v.get("result")) {
88 let tool = v
89 .get("tool_name")
90 .and_then(|t| t.as_str())
91 .unwrap_or("unknown");
92 let tokens = estimate_tokens_json(result);
93 let content_str = match result {
94 serde_json::Value::String(s) => s.clone(),
95 other => other.to_string(),
96 };
97 return Some(ObserveEvent {
98 ts,
99 event_type: "mcp_call",
100 tokens,
101 tool_name: Some(tool.to_string()),
102 detail: v
103 .get("server_name")
104 .and_then(|s| s.as_str())
105 .map(String::from),
106 content: Some(cap_content(&content_str)),
107 model: None,
108 conversation_id: None,
109 });
110 }
111
112 if let Some(output) = v.get("output") {
113 let cmd = v
114 .get("command")
115 .and_then(|c| c.as_str())
116 .unwrap_or("")
117 .to_string();
118 let tokens = estimate_tokens_value(output);
119 let out_str = match output {
120 serde_json::Value::String(s) => s.clone(),
121 other => other.to_string(),
122 };
123 return Some(ObserveEvent {
124 ts,
125 event_type: "shell",
126 tokens,
127 tool_name: None,
128 detail: Some(truncate_str(&cmd, 80)),
129 content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
130 model: None,
131 conversation_id: None,
132 });
133 }
134
135 if v.get("content").is_some() && v.get("file_path").is_some() {
136 let path = v
137 .get("file_path")
138 .and_then(|p| p.as_str())
139 .unwrap_or("")
140 .to_string();
141 let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
142 let tokens = file_content.len() / 4;
143 return Some(ObserveEvent {
144 ts,
145 event_type: "file_read",
146 tokens,
147 tool_name: None,
148 detail: Some(truncate_str(&path, 120)),
149 content: Some(cap_content(file_content)),
150 model: None,
151 conversation_id: None,
152 });
153 }
154
155 if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
156 let has_duration = v.get("duration_ms").is_some();
157 let event_type = if has_duration {
158 "thinking"
159 } else {
160 "agent_response"
161 };
162 let tokens = text.len() / 4;
163 return Some(ObserveEvent {
164 ts,
165 event_type,
166 tokens,
167 tool_name: None,
168 detail: None,
169 content: Some(cap_content(text)),
170 model: None,
171 conversation_id: None,
172 });
173 }
174
175 if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
176 let tokens = prompt.len() / 4;
177 let mut full = prompt.to_string();
178 if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array()) {
179 if !attachments.is_empty() {
180 full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
181 for att in attachments {
182 if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
183 full.push_str(&format!("\n - {name}"));
184 }
185 }
186 }
187 }
188 return Some(ObserveEvent {
189 ts,
190 event_type: "user_message",
191 tokens,
192 tool_name: None,
193 detail: v
194 .get("attachments")
195 .and_then(|a| a.as_array())
196 .map(|a| format!("{} attachments", a.len())),
197 content: Some(cap_content(&full)),
198 model: None,
199 conversation_id: None,
200 });
201 }
202
203 if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
204 let tool = v
205 .get("tool_name")
206 .and_then(|t| t.as_str())
207 .unwrap_or("unknown")
208 .to_string();
209 let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
210 let input_str = v
211 .get("tool_input")
212 .map(std::string::ToString::to_string)
213 .unwrap_or_default();
214 return Some(ObserveEvent {
215 ts,
216 event_type: "native_tool",
217 tokens,
218 tool_name: Some(tool),
219 detail: None,
220 content: if input_str.is_empty() {
221 None
222 } else {
223 Some(cap_content(&input_str))
224 },
225 model: None,
226 conversation_id: None,
227 });
228 }
229
230 if v.get("session_id").is_some() {
231 return Some(ObserveEvent {
232 ts,
233 event_type: "session",
234 tokens: 0,
235 tool_name: None,
236 detail: v
237 .get("session_id")
238 .and_then(|s| s.as_str())
239 .map(String::from),
240 content: None,
241 model: None,
242 conversation_id: None,
243 });
244 }
245
246 let is_compaction = v.get("compaction").is_some()
247 || v.get("messages_count").is_some()
248 || v.get("event")
249 .and_then(|e| e.as_str())
250 .is_some_and(|e| e == "compaction" || e == "compact");
251 if is_compaction {
252 return Some(ObserveEvent {
253 ts,
254 event_type: "compaction",
255 tokens: 0,
256 tool_name: None,
257 detail: None,
258 content: None,
259 model: None,
260 conversation_id: None,
261 });
262 }
263
264 None
265}
266
267fn estimate_tokens_json(v: &serde_json::Value) -> usize {
268 match v {
269 serde_json::Value::String(s) => s.len() / 4,
270 _ => v.to_string().len() / 4,
271 }
272}
273
274fn estimate_tokens_value(v: &serde_json::Value) -> usize {
275 match v {
276 serde_json::Value::String(s) => s.len() / 4,
277 _ => v.to_string().len() / 4,
278 }
279}
280
281fn persist_detected_model(model: &str) {
282 let m = model.to_lowercase();
283 let is_bg_model = m.contains("flash")
284 || m.contains("mini")
285 || m.contains("haiku")
286 || m.contains("fast")
287 || m.contains("nano")
288 || m.contains("small");
289 if is_bg_model {
290 return;
291 }
292
293 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
294 return;
295 };
296 let path = data_dir.join("detected_model.json");
297 let ts = std::time::SystemTime::now()
298 .duration_since(std::time::UNIX_EPOCH)
299 .unwrap_or_default()
300 .as_secs();
301 let window = model_context_window(model);
302 let payload = serde_json::json!({
303 "model": model,
304 "window_size": window,
305 "detected_at": ts,
306 });
307 if let Ok(json) = serde_json::to_string_pretty(&payload) {
308 let tmp = path.with_extension("tmp");
309 if std::fs::write(&tmp, &json).is_ok() {
310 let _ = std::fs::rename(&tmp, &path);
311 }
312 }
313}
314
315pub fn model_context_window(model: &str) -> usize {
316 let m = model.to_lowercase();
317 if m.contains("claude")
318 || m.contains("opus-4")
319 || m.contains("o1")
320 || m.contains("o3")
321 || m.contains("o4")
322 {
323 200_000
324 } else if m.contains("gpt-4")
325 || m.contains("gpt-5")
326 || m.contains("codex")
327 || m.contains("deepseek")
328 {
329 128_000
330 } else if m.contains("gemini") {
331 1_000_000
332 } else if m.contains("mistral") || m.contains("codestral") {
333 256_000
334 } else {
335 200_000
336 }
337}
338
339pub fn load_detected_model() -> Option<(String, usize)> {
340 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
341 let path = data_dir.join("detected_model.json");
342 let content = std::fs::read_to_string(&path).ok()?;
343 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
344 let model = v.get("model")?.as_str()?.to_string();
345 let window = v.get("window_size")?.as_u64()? as usize;
346 let detected_at = v.get("detected_at")?.as_u64()?;
347 let now = std::time::SystemTime::now()
348 .duration_since(std::time::UNIX_EPOCH)
349 .unwrap_or_default()
350 .as_secs();
351 if now.saturating_sub(detected_at) > 7200 {
352 return None;
353 }
354 Some((model, window))
355}
356
357fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
358 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
359 return;
360 };
361 let meta_path = data_dir.join("active_transcript.json");
362 let ts = std::time::SystemTime::now()
363 .duration_since(std::time::UNIX_EPOCH)
364 .unwrap_or_default()
365 .as_secs();
366 let payload = serde_json::json!({
367 "transcript_path": path,
368 "conversation_id": conversation_id,
369 "updated_at": ts,
370 });
371 if let Ok(json) = serde_json::to_string_pretty(&payload) {
372 let tmp = meta_path.with_extension("tmp");
373 if std::fs::write(&tmp, &json).is_ok() {
374 let _ = std::fs::rename(&tmp, &meta_path);
375 }
376 }
377}
378
379pub fn load_active_transcript() -> Option<(String, Option<String>)> {
380 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
381 let path = data_dir.join("active_transcript.json");
382 let content = std::fs::read_to_string(&path).ok()?;
383 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
384 let tp = v.get("transcript_path")?.as_str()?.to_string();
385 let conv = v
386 .get("conversation_id")
387 .and_then(|c| c.as_str())
388 .map(String::from);
389 let updated = v.get("updated_at")?.as_u64()?;
390 let now = std::time::SystemTime::now()
391 .duration_since(std::time::UNIX_EPOCH)
392 .unwrap_or_default()
393 .as_secs();
394 if now.saturating_sub(updated) > 7200 {
395 return None;
396 }
397 Some((tp, conv))
398}
399
400fn cap_content(s: &str) -> String {
401 if s.len() <= MAX_CONTENT_CHARS {
402 s.to_string()
403 } else {
404 format!(
405 "{}…\n\n[truncated: {} total chars]",
406 &s[..MAX_CONTENT_CHARS],
407 s.len()
408 )
409 }
410}
411
412fn truncate_str(s: &str, max: usize) -> String {
413 if s.len() <= max {
414 s.to_string()
415 } else {
416 format!("{}...", &s[..max])
417 }
418}
419
420fn append_radar_event(event: &ObserveEvent) {
421 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
422 return;
423 };
424 let radar_path = data_dir.join("context_radar.jsonl");
425
426 if event.event_type == "session" {
427 if let Ok(meta) = std::fs::metadata(&radar_path) {
428 const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; if meta.len() > MAX_RADAR_SIZE {
430 let prev = data_dir.join("context_radar.prev.jsonl");
431 let _ = std::fs::rename(&radar_path, &prev);
432 }
433 }
434 }
435
436 let Ok(line) = serde_json::to_string(event) else {
437 return;
438 };
439
440 use std::fs::OpenOptions;
441 use std::io::Write;
442 if let Ok(mut f) = OpenOptions::new()
443 .create(true)
444 .append(true)
445 .open(&radar_path)
446 {
447 let _ = writeln!(f, "{line}");
448 }
449}
450
451fn is_disabled() -> bool {
452 std::env::var("LEAN_CTX_DISABLED").is_ok()
453}
454
455fn is_harden_active() -> bool {
456 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
457}
458
459fn is_quiet() -> bool {
460 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
461}
462
463pub fn mark_hook_environment() {
466 std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
467}
468
469pub fn arm_watchdog(timeout: Duration) {
474 std::thread::spawn(move || {
475 std::thread::sleep(timeout);
476 eprintln!(
477 "[lean-ctx hook] watchdog timeout after {}s — force exit",
478 timeout.as_secs()
479 );
480 std::process::exit(1);
481 });
482}
483
484fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
486 let (tx, rx) = mpsc::channel();
487 std::thread::spawn(move || {
488 let mut buf = String::new();
489 let result = std::io::stdin().read_to_string(&mut buf);
490 let _ = tx.send(result.ok().map(|_| buf));
491 });
492 match rx.recv_timeout(timeout) {
493 Ok(Some(s)) if !s.is_empty() => Some(s),
494 _ => None,
495 }
496}
497
498fn build_dual_deny_output(reason: &str) -> String {
499 serde_json::json!({
500 "permission": "deny",
501 "reason": reason,
502 "hookSpecificOutput": {
503 "hookEventName": "PreToolUse",
504 "permissionDecision": "deny",
505 }
506 })
507 .to_string()
508}
509
510fn build_dual_allow_output() -> String {
511 serde_json::json!({
512 "permission": "allow",
513 "hookSpecificOutput": {
514 "hookEventName": "PreToolUse",
515 "permissionDecision": "allow"
516 }
517 })
518 .to_string()
519}
520
521fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
522 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
523 let mut m = obj.clone();
524 m.insert(
525 "command".to_string(),
526 serde_json::Value::String(rewritten.to_string()),
527 );
528 serde_json::Value::Object(m)
529 } else {
530 serde_json::json!({ "command": rewritten })
531 };
532
533 serde_json::json!({
534 "permission": "allow",
536 "updated_input": updated_input,
537 "hookSpecificOutput": {
539 "hookEventName": "PreToolUse",
540 "permissionDecision": "allow",
541 "updatedInput": {
542 "command": rewritten
543 }
544 }
545 })
546 .to_string()
547}
548
549pub fn handle_rewrite() {
550 if is_disabled() {
551 return;
552 }
553 let binary = resolve_binary();
554 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
555 return;
556 };
557
558 let v: serde_json::Value = if let Ok(v) = serde_json::from_str(&input) {
559 v
560 } else {
561 print!("{}", build_dual_deny_output("invalid JSON hook payload"));
562 return;
563 };
564
565 let tool = v.get("tool_name").and_then(|t| t.as_str());
566 let Some(tool_name) = tool else {
567 return;
568 };
569
570 let is_shell_tool = matches!(
572 tool_name,
573 "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
574 );
575 if !is_shell_tool {
576 return;
577 }
578
579 let tool_input = v.get("tool_input");
580 let Some(cmd) = tool_input
581 .and_then(|ti| ti.get("command"))
582 .and_then(|c| c.as_str())
583 .or_else(|| v.get("command").and_then(|c| c.as_str()))
584 else {
585 return;
586 };
587
588 if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
589 print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
590 } else {
591 print!("{}", build_dual_allow_output());
593 }
594}
595
596fn is_rewritable(cmd: &str) -> bool {
597 rewrite_registry::is_rewritable_command(cmd)
598}
599
600fn wrap_single_command(cmd: &str, binary: &str) -> String {
601 let shell_escaped = cmd.replace('\'', "'\\''");
602 format!("{binary} -c '{shell_escaped}'")
603}
604
605fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
606 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
607 return None;
608 }
609
610 if cmd.contains("<<") {
613 return None;
614 }
615
616 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
617 return Some(rewritten);
618 }
619
620 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
621 return Some(rewritten);
622 }
623
624 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
625 return Some(rewritten);
626 }
627
628 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
629 return Some(rewritten);
630 }
631
632 if is_rewritable(cmd) {
633 return Some(wrap_single_command(cmd, binary));
634 }
635
636 None
637}
638
639fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
641 if !rewrite_registry::is_file_read_command(cmd) {
642 return None;
643 }
644
645 let parts: Vec<&str> = cmd.split_whitespace().collect();
646 if parts.len() < 2 {
647 return None;
648 }
649
650 match parts[0] {
651 "cat" => {
652 let path = parts[1..].join(" ");
653 Some(format!("{binary} read {path}"))
654 }
655 "head" => {
656 let (n, path) = parse_head_tail_args(&parts[1..]);
657 let path = path?;
658 match n {
659 Some(lines) => Some(format!("{binary} read {path} -m lines:1-{lines}")),
660 None => Some(format!("{binary} read {path} -m lines:1-10")),
661 }
662 }
663 "tail" => {
664 let (n, path) = parse_head_tail_args(&parts[1..]);
665 let path = path?;
666 let lines = n.unwrap_or(10);
667 Some(format!("{binary} read {path} -m lines:-{lines}"))
668 }
669 _ => None,
670 }
671}
672
673fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
677 let parts: Vec<&str> = cmd.split_whitespace().collect();
678 if parts.first().copied() != Some("rg") {
679 return None;
680 }
681 if parts.len() < 2 {
682 return None;
683 }
684 if parts[1].starts_with('-') {
685 return None;
686 }
687 if parts.len() > 3 {
688 return None;
689 }
690 let pattern = parts[1];
691 let path = parts.get(2).copied();
692 match path {
693 Some(p) if p.starts_with('-') => None,
694 Some(p) => Some(format!("{binary} grep {pattern} {p}")),
695 None => Some(format!("{binary} grep {pattern}")),
696 }
697}
698
699fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
703 let parts: Vec<&str> = cmd.split_whitespace().collect();
704 if parts.first().copied() != Some("ls") {
705 return None;
706 }
707 match parts.len() {
708 1 => Some(format!("{binary} ls")),
709 2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", parts[1])),
710 _ => None,
711 }
712}
713
714fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
715 let mut n: Option<usize> = None;
716 let mut path: Option<&str> = None;
717
718 let mut i = 0;
719 while i < args.len() {
720 if args[i] == "-n" && i + 1 < args.len() {
721 n = args[i + 1].parse().ok();
722 i += 2;
723 } else if let Some(num) = args[i].strip_prefix("-n") {
724 n = num.parse().ok();
725 i += 1;
726 } else if args[i].starts_with('-') && args[i].len() > 1 {
727 if let Ok(num) = args[i][1..].parse::<usize>() {
728 n = Some(num);
729 }
730 i += 1;
731 } else {
732 path = Some(args[i]);
733 i += 1;
734 }
735 }
736
737 (n, path)
738}
739
740fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
741 compound_lexer::rewrite_compound(cmd, |segment| {
742 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
743 return None;
744 }
745 if is_rewritable(segment) {
746 Some(wrap_single_command(segment, binary))
747 } else {
748 None
749 }
750 })
751}
752
753fn emit_rewrite(rewritten: &str) {
754 let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
755 print!(
756 "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
757 );
758}
759
760pub fn handle_redirect() {
761 if is_disabled() {
762 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
763 print!("{}", build_dual_allow_output());
764 return;
765 }
766
767 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
768 return;
769 };
770
771 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
772 print!("{}", build_dual_deny_output("invalid JSON hook payload"));
773 return;
774 };
775
776 let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
777 let tool_input = v.get("tool_input");
778
779 match tool_name {
780 "Read" | "read" | "read_file" => redirect_read(tool_input),
781 "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
782 _ => print!("{}", build_dual_allow_output()),
783 }
784}
785
786fn redirect_read(tool_input: Option<&serde_json::Value>) {
790 let path = tool_input
791 .and_then(|ti| ti.get("path"))
792 .and_then(|p| p.as_str())
793 .unwrap_or("");
794
795 if path.is_empty() || should_passthrough(path) {
796 print!("{}", build_dual_allow_output());
797 return;
798 }
799
800 if is_harden_active() {
801 print!(
802 "{}",
803 build_dual_deny_output(
804 "Use ctx_read instead of native Read. lean-ctx harden mode is active."
805 )
806 );
807 return;
808 }
809
810 let binary = resolve_binary();
811 let temp_path = redirect_temp_path(path);
812
813 if let Some(output) = run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT) {
814 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
815 let temp_str = temp_path.to_str().unwrap_or("");
816 print!("{}", build_redirect_output(tool_input, "path", temp_str));
817 return;
818 }
819 }
820
821 print!("{}", build_dual_allow_output());
822}
823
824fn redirect_grep(tool_input: Option<&serde_json::Value>) {
826 let pattern = tool_input
827 .and_then(|ti| ti.get("pattern"))
828 .and_then(|p| p.as_str())
829 .unwrap_or("");
830 let search_path = tool_input
831 .and_then(|ti| ti.get("path"))
832 .and_then(|p| p.as_str())
833 .unwrap_or(".");
834
835 if pattern.is_empty() {
836 print!("{}", build_dual_allow_output());
837 return;
838 }
839
840 if is_harden_active() {
841 print!(
842 "{}",
843 build_dual_deny_output(
844 "Use ctx_search instead of native Grep. lean-ctx harden mode is active."
845 )
846 );
847 return;
848 }
849
850 let binary = resolve_binary();
851 let key = format!("grep:{pattern}:{search_path}");
852 let temp_path = redirect_temp_path(&key);
853
854 if let Some(output) = run_with_timeout(
855 &binary,
856 &["grep", pattern, search_path],
857 REDIRECT_SUBPROCESS_TIMEOUT,
858 ) {
859 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
860 let temp_str = temp_path.to_str().unwrap_or("");
861 print!("{}", build_redirect_output(tool_input, "path", temp_str));
862 return;
863 }
864 }
865
866 print!("{}", build_dual_allow_output());
867}
868
869const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
870
871fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
874 let mut child = std::process::Command::new(binary)
875 .args(args)
876 .stdout(std::process::Stdio::piped())
877 .stderr(std::process::Stdio::null())
878 .spawn()
879 .ok()?;
880
881 let deadline = std::time::Instant::now() + timeout;
882 loop {
883 match child.try_wait() {
884 Ok(Some(status)) if status.success() => {
885 let mut stdout = Vec::new();
886 if let Some(mut out) = child.stdout.take() {
887 let _ = out.read_to_end(&mut stdout);
888 }
889 return if stdout.is_empty() {
890 None
891 } else {
892 Some(stdout)
893 };
894 }
895 Ok(Some(_)) | Err(_) => return None,
896 Ok(None) => {
897 if std::time::Instant::now() > deadline {
898 let _ = child.kill();
899 let _ = child.wait();
900 return None;
901 }
902 std::thread::sleep(Duration::from_millis(10));
903 }
904 }
905 }
906}
907
908fn redirect_temp_path(key: &str) -> std::path::PathBuf {
909 use std::collections::hash_map::DefaultHasher;
910 use std::hash::{Hash, Hasher};
911
912 let mut hasher = DefaultHasher::new();
913 key.hash(&mut hasher);
914 std::process::id().hash(&mut hasher);
915 let hash = hasher.finish();
916
917 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
918 let _ = std::fs::create_dir_all(&temp_dir);
919 #[cfg(unix)]
920 {
921 use std::os::unix::fs::PermissionsExt;
922 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
923 }
924 temp_dir.join(format!("{hash:016x}.lctx"))
925}
926
927fn build_redirect_output(
928 tool_input: Option<&serde_json::Value>,
929 field: &str,
930 temp_path: &str,
931) -> String {
932 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
933 let mut m = obj.clone();
934 m.insert(
935 field.to_string(),
936 serde_json::Value::String(temp_path.to_string()),
937 );
938 serde_json::Value::Object(m)
939 } else {
940 serde_json::json!({ field: temp_path })
941 };
942
943 serde_json::json!({
944 "permission": "allow",
945 "updated_input": updated_input,
946 "hookSpecificOutput": {
947 "hookEventName": "PreToolUse",
948 "permissionDecision": "allow",
949 "updatedInput": { field: temp_path }
950 }
951 })
952 .to_string()
953}
954
955const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
956 ".cursorrules",
957 ".cursor/rules",
958 ".cursor/hooks",
959 "skill.md",
960 "agents.md",
961 ".env",
962 "hooks.json",
963 "node_modules",
964];
965
966const PASSTHROUGH_EXTENSIONS: &[&str] = &[
967 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
968];
969
970fn should_passthrough(path: &str) -> bool {
971 let p = path.to_lowercase();
972
973 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
974 return true;
975 }
976
977 std::path::Path::new(&p)
978 .extension()
979 .and_then(|ext| ext.to_str())
980 .is_some_and(|ext| {
981 PASSTHROUGH_EXTENSIONS
982 .iter()
983 .any(|e| ext.eq_ignore_ascii_case(e))
984 })
985}
986
987fn codex_reroute_message(rewritten: &str) -> String {
988 format!(
989 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
990 )
991}
992
993pub fn handle_codex_pretooluse() {
994 if is_disabled() {
995 return;
996 }
997 let binary = resolve_binary();
998 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
999 return;
1000 };
1001
1002 let tool = extract_json_field(&input, "tool_name");
1003 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1004 return;
1005 }
1006
1007 let Some(cmd) = extract_json_field(&input, "command") else {
1008 return;
1009 };
1010
1011 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1012 if is_quiet() {
1013 eprintln!("Re-run: {rewritten}");
1014 } else {
1015 eprintln!("{}", codex_reroute_message(&rewritten));
1016 }
1017 std::process::exit(2);
1018 }
1019}
1020
1021pub fn handle_codex_session_start() {
1022 if is_quiet() {
1023 return;
1024 }
1025 println!(
1026 "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."
1027 );
1028}
1029
1030pub fn handle_copilot() {
1034 if is_disabled() {
1035 return;
1036 }
1037 let binary = resolve_binary();
1038 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1039 return;
1040 };
1041
1042 let tool = extract_json_field(&input, "tool_name");
1043 let Some(tool_name) = tool.as_deref() else {
1044 return;
1045 };
1046
1047 let is_shell_tool = matches!(
1048 tool_name,
1049 "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
1050 );
1051 if !is_shell_tool {
1052 return;
1053 }
1054
1055 let Some(cmd) = extract_json_field(&input, "command") else {
1056 return;
1057 };
1058
1059 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1060 emit_rewrite(&rewritten);
1061 }
1062}
1063
1064pub fn handle_rewrite_inline() {
1069 if is_disabled() {
1070 return;
1071 }
1072 let binary = resolve_binary_native();
1073 let args: Vec<String> = std::env::args().collect();
1074 if args.len() < 4 {
1076 return;
1077 }
1078 let cmd = args[3..].join(" ");
1079
1080 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1081 print!("{rewritten}");
1082 return;
1083 }
1084
1085 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1086 print!("{cmd}");
1087 return;
1088 }
1089
1090 print!("{cmd}");
1091}
1092
1093fn resolve_binary() -> String {
1094 let path = crate::core::portable_binary::resolve_portable_binary();
1095 crate::hooks::to_bash_compatible_path(&path)
1096}
1097
1098fn resolve_binary_native() -> String {
1099 crate::core::portable_binary::resolve_portable_binary()
1100}
1101
1102fn extract_json_field(input: &str, field: &str) -> Option<String> {
1103 let key = format!("\"{field}\":");
1104 let key_pos = input.find(&key)?;
1105 let after_colon = &input[key_pos + key.len()..];
1106 let trimmed = after_colon.trim_start();
1107 if !trimmed.starts_with('"') {
1108 return None;
1109 }
1110 let rest = &trimmed[1..];
1111 let bytes = rest.as_bytes();
1112 let mut end = 0;
1113 while end < bytes.len() {
1114 if bytes[end] == b'\\' && end + 1 < bytes.len() {
1115 end += 2;
1116 continue;
1117 }
1118 if bytes[end] == b'"' {
1119 break;
1120 }
1121 end += 1;
1122 }
1123 if end >= bytes.len() {
1124 return None;
1125 }
1126 let raw = &rest[..end];
1127 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133
1134 #[test]
1135 fn is_rewritable_basic() {
1136 assert!(is_rewritable("git status"));
1137 assert!(is_rewritable("cargo test --lib"));
1138 assert!(is_rewritable("npm run build"));
1139 assert!(!is_rewritable("echo hello"));
1140 assert!(!is_rewritable("cd src"));
1141 assert!(!is_rewritable("cat file.rs"));
1142 }
1143
1144 #[test]
1145 fn file_read_rewrite_cat() {
1146 let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
1147 assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
1148 }
1149
1150 #[test]
1151 fn file_read_rewrite_head_with_n() {
1152 let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
1153 assert_eq!(
1154 r,
1155 Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
1156 );
1157 }
1158
1159 #[test]
1160 fn file_read_rewrite_head_short() {
1161 let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
1162 assert_eq!(
1163 r,
1164 Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
1165 );
1166 }
1167
1168 #[test]
1169 fn file_read_rewrite_tail() {
1170 let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
1171 assert_eq!(
1172 r,
1173 Some("lean-ctx read src/main.rs -m lines:-10".to_string())
1174 );
1175 }
1176
1177 #[test]
1178 fn file_read_rewrite_not_git() {
1179 assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
1180 }
1181
1182 #[test]
1183 fn parse_head_tail_args_basic() {
1184 let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
1185 assert_eq!(n, Some(20));
1186 assert_eq!(path, Some("file.rs"));
1187 }
1188
1189 #[test]
1190 fn parse_head_tail_args_combined() {
1191 let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
1192 assert_eq!(n, Some(20));
1193 assert_eq!(path, Some("file.rs"));
1194 }
1195
1196 #[test]
1197 fn parse_head_tail_args_short_flag() {
1198 let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
1199 assert_eq!(n, Some(50));
1200 assert_eq!(path, Some("file.rs"));
1201 }
1202
1203 #[test]
1204 fn should_passthrough_rules_files() {
1205 assert!(should_passthrough("/home/user/.cursorrules"));
1206 assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
1207 assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
1208 assert!(should_passthrough("/project/SKILL.md"));
1209 assert!(should_passthrough("/project/AGENTS.md"));
1210 assert!(should_passthrough("/project/icon.png"));
1211 assert!(!should_passthrough("/project/src/main.rs"));
1212 assert!(!should_passthrough("/project/src/lib.ts"));
1213 }
1214
1215 #[test]
1216 fn wrap_single() {
1217 let r = wrap_single_command("git status", "lean-ctx");
1218 assert_eq!(r, "lean-ctx -c 'git status'");
1219 }
1220
1221 #[test]
1222 fn wrap_with_quotes() {
1223 let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
1224 assert_eq!(r, r#"lean-ctx -c 'curl -H "Auth" https://api.com'"#);
1225 }
1226
1227 #[test]
1228 fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
1229 assert_eq!(
1230 rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
1231 None
1232 );
1233 }
1234
1235 #[test]
1236 fn rewrite_candidate_wraps_single_command() {
1237 assert_eq!(
1238 rewrite_candidate("git status", "lean-ctx"),
1239 Some("lean-ctx -c 'git status'".to_string())
1240 );
1241 }
1242
1243 #[test]
1244 fn rewrite_candidate_passes_through_heredoc() {
1245 assert_eq!(
1246 rewrite_candidate(
1247 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1248 "lean-ctx"
1249 ),
1250 None
1251 );
1252 }
1253
1254 #[test]
1255 fn rewrite_candidate_passes_through_heredoc_compound() {
1256 assert_eq!(
1257 rewrite_candidate(
1258 "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
1259 "lean-ctx"
1260 ),
1261 None
1262 );
1263 }
1264
1265 #[test]
1266 fn codex_reroute_message_includes_exact_rewritten_command() {
1267 let message = codex_reroute_message("lean-ctx -c 'git status'");
1268 assert_eq!(
1269 message,
1270 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
1271 );
1272 }
1273
1274 #[test]
1275 fn compound_rewrite_and_chain() {
1276 let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
1277 assert_eq!(
1278 result,
1279 Some("cd src && lean-ctx -c 'git status' && echo done".into())
1280 );
1281 }
1282
1283 #[test]
1284 fn compound_rewrite_pipe() {
1285 let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
1286 assert_eq!(
1287 result,
1288 Some("lean-ctx -c 'git log --oneline' | head -5".into())
1289 );
1290 }
1291
1292 #[test]
1293 fn compound_rewrite_no_match() {
1294 let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
1295 assert_eq!(result, None);
1296 }
1297
1298 #[test]
1299 fn compound_rewrite_multiple_rewritable() {
1300 let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
1301 assert_eq!(
1302 result,
1303 Some(
1304 "lean-ctx -c 'git add .' && lean-ctx -c 'cargo test' && lean-ctx -c 'npm run lint'"
1305 .into()
1306 )
1307 );
1308 }
1309
1310 #[test]
1311 fn compound_rewrite_semicolons() {
1312 let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
1313 assert_eq!(
1314 result,
1315 Some("lean-ctx -c 'git add .' ; lean-ctx -c 'git commit -m '\\''fix'\\'''".into())
1316 );
1317 }
1318
1319 #[test]
1320 fn compound_rewrite_or_chain() {
1321 let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
1322 assert_eq!(result, Some("lean-ctx -c 'git pull' || echo failed".into()));
1323 }
1324
1325 #[test]
1326 fn compound_skips_already_rewritten() {
1327 let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
1328 assert_eq!(
1329 result,
1330 Some("lean-ctx -c git status && lean-ctx -c 'git diff'".into())
1331 );
1332 }
1333
1334 #[test]
1335 fn single_command_not_compound() {
1336 let result = build_rewrite_compound("git status", "lean-ctx");
1337 assert_eq!(result, None);
1338 }
1339
1340 #[test]
1341 fn extract_field_works() {
1342 let input = r#"{"tool_name":"Bash","command":"git status"}"#;
1343 assert_eq!(
1344 extract_json_field(input, "tool_name"),
1345 Some("Bash".to_string())
1346 );
1347 assert_eq!(
1348 extract_json_field(input, "command"),
1349 Some("git status".to_string())
1350 );
1351 }
1352
1353 #[test]
1354 fn extract_field_with_spaces_after_colon() {
1355 let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
1356 assert_eq!(
1357 extract_json_field(input, "tool_name"),
1358 Some("Bash".to_string())
1359 );
1360 assert_eq!(
1361 extract_json_field(input, "command"),
1362 Some("git status".to_string())
1363 );
1364 }
1365
1366 #[test]
1367 fn extract_field_pretty_printed() {
1368 let input = "{\n \"tool_name\": \"Bash\",\n \"tool_input\": {\n \"command\": \"npm test\"\n }\n}";
1369 assert_eq!(
1370 extract_json_field(input, "tool_name"),
1371 Some("Bash".to_string())
1372 );
1373 assert_eq!(
1374 extract_json_field(input, "command"),
1375 Some("npm test".to_string())
1376 );
1377 }
1378
1379 #[test]
1380 fn extract_field_handles_escaped_quotes() {
1381 let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
1382 assert_eq!(
1383 extract_json_field(input, "command"),
1384 Some(r#"grep -r "TODO" src/"#.to_string())
1385 );
1386 }
1387
1388 #[test]
1389 fn extract_field_handles_escaped_backslash() {
1390 let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
1391 assert_eq!(
1392 extract_json_field(input, "command"),
1393 Some(r#"echo \"hello\""#.to_string())
1394 );
1395 }
1396
1397 #[test]
1398 fn extract_field_handles_complex_curl() {
1399 let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
1400 assert_eq!(
1401 extract_json_field(input, "command"),
1402 Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
1403 );
1404 }
1405
1406 #[test]
1407 fn to_bash_compatible_path_windows_drive() {
1408 let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1409 assert_eq!(p, "/e/packages/lean-ctx.exe");
1410 }
1411
1412 #[test]
1413 fn to_bash_compatible_path_backslashes() {
1414 let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
1415 assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
1416 }
1417
1418 #[test]
1419 fn to_bash_compatible_path_unix_unchanged() {
1420 let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
1421 assert_eq!(p, "/usr/local/bin/lean-ctx");
1422 }
1423
1424 #[test]
1425 fn to_bash_compatible_path_msys2_unchanged() {
1426 let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
1427 assert_eq!(p, "/e/packages/lean-ctx.exe");
1428 }
1429
1430 #[test]
1431 fn wrap_command_with_bash_path() {
1432 let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1433 let result = wrap_single_command("git status", &binary);
1434 assert!(
1435 !result.contains('\\'),
1436 "wrapped command must not contain backslashes, got: {result}"
1437 );
1438 assert!(
1439 result.starts_with("/e/packages/lean-ctx.exe"),
1440 "must use bash-compatible path, got: {result}"
1441 );
1442 }
1443
1444 #[test]
1445 fn wrap_single_command_em_dash() {
1446 let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
1447 assert_eq!(r, "lean-ctx -c 'gh --comment \"closing — see #407\"'");
1448 }
1449
1450 #[test]
1451 fn wrap_single_command_dollar_sign() {
1452 let r = wrap_single_command("echo $HOME", "lean-ctx");
1453 assert_eq!(r, "lean-ctx -c 'echo $HOME'");
1454 }
1455
1456 #[test]
1457 fn wrap_single_command_backticks() {
1458 let r = wrap_single_command("echo `date`", "lean-ctx");
1459 assert_eq!(r, "lean-ctx -c 'echo `date`'");
1460 }
1461
1462 #[test]
1463 fn wrap_single_command_nested_single_quotes() {
1464 let r = wrap_single_command("echo 'hello world'", "lean-ctx");
1465 assert_eq!(r, r"lean-ctx -c 'echo '\''hello world'\'''");
1466 }
1467
1468 #[test]
1469 fn wrap_single_command_exclamation_mark() {
1470 let r = wrap_single_command("echo hello!", "lean-ctx");
1471 assert_eq!(r, "lean-ctx -c 'echo hello!'");
1472 }
1473
1474 #[test]
1475 fn wrap_single_command_find_with_many_excludes() {
1476 let r = wrap_single_command(
1477 "find . -not -path ./node_modules -not -path ./.git -not -path ./dist",
1478 "lean-ctx",
1479 );
1480 assert_eq!(
1481 r,
1482 "lean-ctx -c 'find . -not -path ./node_modules -not -path ./.git -not -path ./dist'"
1483 );
1484 }
1485}