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 crate::core::model_registry::context_window_for_model(model)
317}
318
319pub fn load_detected_model() -> Option<(String, usize)> {
320 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
321 let path = data_dir.join("detected_model.json");
322 let content = std::fs::read_to_string(&path).ok()?;
323 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
324 let model = v.get("model")?.as_str()?.to_string();
325 let window = v.get("window_size")?.as_u64()? as usize;
326 let detected_at = v.get("detected_at")?.as_u64()?;
327 let now = std::time::SystemTime::now()
328 .duration_since(std::time::UNIX_EPOCH)
329 .unwrap_or_default()
330 .as_secs();
331 if now.saturating_sub(detected_at) > 7200 {
332 return None;
333 }
334 Some((model, window))
335}
336
337fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
338 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
339 return;
340 };
341 let meta_path = data_dir.join("active_transcript.json");
342 let ts = std::time::SystemTime::now()
343 .duration_since(std::time::UNIX_EPOCH)
344 .unwrap_or_default()
345 .as_secs();
346 let payload = serde_json::json!({
347 "transcript_path": path,
348 "conversation_id": conversation_id,
349 "updated_at": ts,
350 });
351 if let Ok(json) = serde_json::to_string_pretty(&payload) {
352 let tmp = meta_path.with_extension("tmp");
353 if std::fs::write(&tmp, &json).is_ok() {
354 let _ = std::fs::rename(&tmp, &meta_path);
355 }
356 }
357}
358
359pub fn load_active_transcript() -> Option<(String, Option<String>)> {
360 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
361 let path = data_dir.join("active_transcript.json");
362 let content = std::fs::read_to_string(&path).ok()?;
363 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
364 let tp = v.get("transcript_path")?.as_str()?.to_string();
365 let conv = v
366 .get("conversation_id")
367 .and_then(|c| c.as_str())
368 .map(String::from);
369 let updated = v.get("updated_at")?.as_u64()?;
370 let now = std::time::SystemTime::now()
371 .duration_since(std::time::UNIX_EPOCH)
372 .unwrap_or_default()
373 .as_secs();
374 if now.saturating_sub(updated) > 7200 {
375 return None;
376 }
377 Some((tp, conv))
378}
379
380fn cap_content(s: &str) -> String {
381 if s.len() <= MAX_CONTENT_CHARS {
382 s.to_string()
383 } else {
384 format!(
385 "{}…\n\n[truncated: {} total chars]",
386 &s[..MAX_CONTENT_CHARS],
387 s.len()
388 )
389 }
390}
391
392fn truncate_str(s: &str, max: usize) -> String {
393 if s.len() <= max {
394 s.to_string()
395 } else {
396 format!("{}...", &s[..max])
397 }
398}
399
400fn append_radar_event(event: &ObserveEvent) {
401 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
402 return;
403 };
404 let radar_path = data_dir.join("context_radar.jsonl");
405
406 if event.event_type == "session" {
407 if let Ok(meta) = std::fs::metadata(&radar_path) {
408 const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; if meta.len() > MAX_RADAR_SIZE {
410 let prev = data_dir.join("context_radar.prev.jsonl");
411 let _ = std::fs::rename(&radar_path, &prev);
412 }
413 }
414 }
415
416 let Ok(line) = serde_json::to_string(event) else {
417 return;
418 };
419
420 use std::fs::OpenOptions;
421 use std::io::Write;
422 if let Ok(mut f) = OpenOptions::new()
423 .create(true)
424 .append(true)
425 .open(&radar_path)
426 {
427 let _ = writeln!(f, "{line}");
428 }
429}
430
431fn is_disabled() -> bool {
432 std::env::var("LEAN_CTX_DISABLED").is_ok()
433}
434
435fn is_harden_active() -> bool {
436 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
437}
438
439fn is_quiet() -> bool {
440 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
441}
442
443pub fn mark_hook_environment() {
446 std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
447}
448
449pub fn arm_watchdog(timeout: Duration) {
454 std::thread::spawn(move || {
455 std::thread::sleep(timeout);
456 eprintln!(
457 "[lean-ctx hook] watchdog timeout after {}s — force exit",
458 timeout.as_secs()
459 );
460 std::process::exit(1);
461 });
462}
463
464fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
466 let (tx, rx) = mpsc::channel();
467 std::thread::spawn(move || {
468 let mut buf = String::new();
469 let result = std::io::stdin().read_to_string(&mut buf);
470 let _ = tx.send(result.ok().map(|_| buf));
471 });
472 match rx.recv_timeout(timeout) {
473 Ok(Some(s)) if !s.is_empty() => Some(s),
474 _ => None,
475 }
476}
477
478fn build_dual_deny_output(reason: &str) -> String {
479 serde_json::json!({
480 "permission": "deny",
481 "reason": reason,
482 "hookSpecificOutput": {
483 "hookEventName": "PreToolUse",
484 "permissionDecision": "deny",
485 }
486 })
487 .to_string()
488}
489
490fn build_dual_allow_output() -> String {
491 serde_json::json!({
492 "permission": "allow",
493 "hookSpecificOutput": {
494 "hookEventName": "PreToolUse",
495 "permissionDecision": "allow"
496 }
497 })
498 .to_string()
499}
500
501fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
502 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
503 let mut m = obj.clone();
504 m.insert(
505 "command".to_string(),
506 serde_json::Value::String(rewritten.to_string()),
507 );
508 serde_json::Value::Object(m)
509 } else {
510 serde_json::json!({ "command": rewritten })
511 };
512
513 serde_json::json!({
514 "permission": "allow",
516 "updated_input": updated_input,
517 "hookSpecificOutput": {
519 "hookEventName": "PreToolUse",
520 "permissionDecision": "allow",
521 "updatedInput": {
522 "command": rewritten
523 }
524 }
525 })
526 .to_string()
527}
528
529pub fn handle_rewrite() {
530 if is_disabled() {
531 return;
532 }
533 let binary = resolve_binary();
534 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
535 return;
536 };
537
538 let v: serde_json::Value = if let Ok(v) = serde_json::from_str(&input) {
539 v
540 } else {
541 print!("{}", build_dual_deny_output("invalid JSON hook payload"));
542 return;
543 };
544
545 let tool = v.get("tool_name").and_then(|t| t.as_str());
546 let Some(tool_name) = tool else {
547 return;
548 };
549
550 let is_shell_tool = matches!(
552 tool_name,
553 "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
554 );
555 if !is_shell_tool {
556 return;
557 }
558
559 let tool_input = v.get("tool_input");
560 let Some(cmd) = tool_input
561 .and_then(|ti| ti.get("command"))
562 .and_then(|c| c.as_str())
563 .or_else(|| v.get("command").and_then(|c| c.as_str()))
564 else {
565 return;
566 };
567
568 if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
569 print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
570 } else {
571 print!("{}", build_dual_allow_output());
573 }
574}
575
576fn is_rewritable(cmd: &str) -> bool {
577 rewrite_registry::is_rewritable_command(cmd)
578}
579
580fn wrap_single_command(cmd: &str, binary: &str) -> String {
581 let shell_escaped = cmd.replace('\'', "'\\''");
582 format!("{binary} -c '{shell_escaped}'")
583}
584
585fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
586 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
587 return None;
588 }
589
590 if cmd.contains("<<") {
593 return None;
594 }
595
596 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
597 return Some(rewritten);
598 }
599
600 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
601 return Some(rewritten);
602 }
603
604 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
605 return Some(rewritten);
606 }
607
608 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
609 return Some(rewritten);
610 }
611
612 if is_rewritable(cmd) {
613 return Some(wrap_single_command(cmd, binary));
614 }
615
616 None
617}
618
619fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
621 if !rewrite_registry::is_file_read_command(cmd) {
622 return None;
623 }
624
625 let parts: Vec<&str> = cmd.split_whitespace().collect();
626 if parts.len() < 2 {
627 return None;
628 }
629
630 match parts[0] {
631 "cat" => {
632 let path = parts[1..].join(" ");
633 Some(format!("{binary} read {path}"))
634 }
635 "head" => {
636 let (n, path) = parse_head_tail_args(&parts[1..]);
637 let path = path?;
638 match n {
639 Some(lines) => Some(format!("{binary} read {path} -m lines:1-{lines}")),
640 None => Some(format!("{binary} read {path} -m lines:1-10")),
641 }
642 }
643 "tail" => {
644 let (n, path) = parse_head_tail_args(&parts[1..]);
645 let path = path?;
646 let lines = n.unwrap_or(10);
647 Some(format!("{binary} read {path} -m lines:-{lines}"))
648 }
649 _ => None,
650 }
651}
652
653fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
657 let parts: Vec<&str> = cmd.split_whitespace().collect();
658 if parts.first().copied() != Some("rg") {
659 return None;
660 }
661 if parts.len() < 2 {
662 return None;
663 }
664 if parts[1].starts_with('-') {
665 return None;
666 }
667 if parts.len() > 3 {
668 return None;
669 }
670 let pattern = parts[1];
671 let path = parts.get(2).copied();
672 match path {
673 Some(p) if p.starts_with('-') => None,
674 Some(p) => Some(format!("{binary} grep {pattern} {p}")),
675 None => Some(format!("{binary} grep {pattern}")),
676 }
677}
678
679fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
683 let parts: Vec<&str> = cmd.split_whitespace().collect();
684 if parts.first().copied() != Some("ls") {
685 return None;
686 }
687 match parts.len() {
688 1 => Some(format!("{binary} ls")),
689 2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", parts[1])),
690 _ => None,
691 }
692}
693
694fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
695 let mut n: Option<usize> = None;
696 let mut path: Option<&str> = None;
697
698 let mut i = 0;
699 while i < args.len() {
700 if args[i] == "-n" && i + 1 < args.len() {
701 n = args[i + 1].parse().ok();
702 i += 2;
703 } else if let Some(num) = args[i].strip_prefix("-n") {
704 n = num.parse().ok();
705 i += 1;
706 } else if args[i].starts_with('-') && args[i].len() > 1 {
707 if let Ok(num) = args[i][1..].parse::<usize>() {
708 n = Some(num);
709 }
710 i += 1;
711 } else {
712 path = Some(args[i]);
713 i += 1;
714 }
715 }
716
717 (n, path)
718}
719
720fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
721 compound_lexer::rewrite_compound(cmd, |segment| {
722 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
723 return None;
724 }
725 if is_rewritable(segment) {
726 Some(wrap_single_command(segment, binary))
727 } else {
728 None
729 }
730 })
731}
732
733fn emit_rewrite(rewritten: &str) {
734 let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
735 print!(
736 "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
737 );
738}
739
740pub fn handle_redirect() {
741 if is_disabled() {
742 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
743 print!("{}", build_dual_allow_output());
744 return;
745 }
746
747 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
748 return;
749 };
750
751 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
752 print!("{}", build_dual_deny_output("invalid JSON hook payload"));
753 return;
754 };
755
756 let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
757 let tool_input = v.get("tool_input");
758
759 match tool_name {
760 "Read" | "read" | "read_file" => redirect_read(tool_input),
761 "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
762 _ => print!("{}", build_dual_allow_output()),
763 }
764}
765
766fn redirect_read(tool_input: Option<&serde_json::Value>) {
770 let path = tool_input
771 .and_then(|ti| ti.get("path"))
772 .and_then(|p| p.as_str())
773 .unwrap_or("");
774
775 if path.is_empty() || should_passthrough(path) {
776 print!("{}", build_dual_allow_output());
777 return;
778 }
779
780 if is_harden_active() {
781 print!(
782 "{}",
783 build_dual_deny_output(
784 "Use ctx_read instead of native Read. lean-ctx harden mode is active."
785 )
786 );
787 return;
788 }
789
790 let binary = resolve_binary();
791 let temp_path = redirect_temp_path(path);
792
793 if let Some(output) = run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT) {
794 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
795 let temp_str = temp_path.to_str().unwrap_or("");
796 print!("{}", build_redirect_output(tool_input, "path", temp_str));
797 return;
798 }
799 }
800
801 print!("{}", build_dual_allow_output());
802}
803
804fn redirect_grep(tool_input: Option<&serde_json::Value>) {
806 let pattern = tool_input
807 .and_then(|ti| ti.get("pattern"))
808 .and_then(|p| p.as_str())
809 .unwrap_or("");
810 let search_path = tool_input
811 .and_then(|ti| ti.get("path"))
812 .and_then(|p| p.as_str())
813 .unwrap_or(".");
814
815 if pattern.is_empty() {
816 print!("{}", build_dual_allow_output());
817 return;
818 }
819
820 if is_harden_active() {
821 print!(
822 "{}",
823 build_dual_deny_output(
824 "Use ctx_search instead of native Grep. lean-ctx harden mode is active."
825 )
826 );
827 return;
828 }
829
830 let binary = resolve_binary();
831 let key = format!("grep:{pattern}:{search_path}");
832 let temp_path = redirect_temp_path(&key);
833
834 if let Some(output) = run_with_timeout(
835 &binary,
836 &["grep", pattern, search_path],
837 REDIRECT_SUBPROCESS_TIMEOUT,
838 ) {
839 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
840 let temp_str = temp_path.to_str().unwrap_or("");
841 print!("{}", build_redirect_output(tool_input, "path", temp_str));
842 return;
843 }
844 }
845
846 print!("{}", build_dual_allow_output());
847}
848
849const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
850
851fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
854 let mut child = std::process::Command::new(binary)
855 .args(args)
856 .stdout(std::process::Stdio::piped())
857 .stderr(std::process::Stdio::null())
858 .spawn()
859 .ok()?;
860
861 let deadline = std::time::Instant::now() + timeout;
862 loop {
863 match child.try_wait() {
864 Ok(Some(status)) if status.success() => {
865 let mut stdout = Vec::new();
866 if let Some(mut out) = child.stdout.take() {
867 let _ = out.read_to_end(&mut stdout);
868 }
869 return if stdout.is_empty() {
870 None
871 } else {
872 Some(stdout)
873 };
874 }
875 Ok(Some(_)) | Err(_) => return None,
876 Ok(None) => {
877 if std::time::Instant::now() > deadline {
878 let _ = child.kill();
879 let _ = child.wait();
880 return None;
881 }
882 std::thread::sleep(Duration::from_millis(10));
883 }
884 }
885 }
886}
887
888fn redirect_temp_path(key: &str) -> std::path::PathBuf {
889 use std::collections::hash_map::DefaultHasher;
890 use std::hash::{Hash, Hasher};
891
892 let mut hasher = DefaultHasher::new();
893 key.hash(&mut hasher);
894 std::process::id().hash(&mut hasher);
895 let hash = hasher.finish();
896
897 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
898 let _ = std::fs::create_dir_all(&temp_dir);
899 #[cfg(unix)]
900 {
901 use std::os::unix::fs::PermissionsExt;
902 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
903 }
904 temp_dir.join(format!("{hash:016x}.lctx"))
905}
906
907fn build_redirect_output(
908 tool_input: Option<&serde_json::Value>,
909 field: &str,
910 temp_path: &str,
911) -> String {
912 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
913 let mut m = obj.clone();
914 m.insert(
915 field.to_string(),
916 serde_json::Value::String(temp_path.to_string()),
917 );
918 serde_json::Value::Object(m)
919 } else {
920 serde_json::json!({ field: temp_path })
921 };
922
923 serde_json::json!({
924 "permission": "allow",
925 "updated_input": updated_input,
926 "hookSpecificOutput": {
927 "hookEventName": "PreToolUse",
928 "permissionDecision": "allow",
929 "updatedInput": { field: temp_path }
930 }
931 })
932 .to_string()
933}
934
935const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
936 ".cursorrules",
937 ".cursor/rules",
938 ".cursor/hooks",
939 "skill.md",
940 "agents.md",
941 ".env",
942 "hooks.json",
943 "node_modules",
944];
945
946const PASSTHROUGH_EXTENSIONS: &[&str] = &[
947 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
948];
949
950fn should_passthrough(path: &str) -> bool {
951 let p = path.to_lowercase();
952
953 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
954 return true;
955 }
956
957 std::path::Path::new(&p)
958 .extension()
959 .and_then(|ext| ext.to_str())
960 .is_some_and(|ext| {
961 PASSTHROUGH_EXTENSIONS
962 .iter()
963 .any(|e| ext.eq_ignore_ascii_case(e))
964 })
965}
966
967fn codex_reroute_message(rewritten: &str) -> String {
968 format!(
969 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
970 )
971}
972
973pub fn handle_codex_pretooluse() {
974 if is_disabled() {
975 return;
976 }
977 let binary = resolve_binary();
978 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
979 return;
980 };
981
982 let tool = extract_json_field(&input, "tool_name");
983 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
984 return;
985 }
986
987 let Some(cmd) = extract_json_field(&input, "command") else {
988 return;
989 };
990
991 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
992 if is_quiet() {
993 eprintln!("Re-run: {rewritten}");
994 } else {
995 eprintln!("{}", codex_reroute_message(&rewritten));
996 }
997 std::process::exit(2);
998 }
999}
1000
1001pub fn handle_codex_session_start() {
1002 if is_quiet() {
1003 return;
1004 }
1005 println!(
1006 "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."
1007 );
1008}
1009
1010pub fn handle_copilot() {
1014 if is_disabled() {
1015 return;
1016 }
1017 let binary = resolve_binary();
1018 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1019 return;
1020 };
1021
1022 let tool = extract_json_field(&input, "tool_name");
1023 let Some(tool_name) = tool.as_deref() else {
1024 return;
1025 };
1026
1027 let is_shell_tool = matches!(
1028 tool_name,
1029 "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
1030 );
1031 if !is_shell_tool {
1032 return;
1033 }
1034
1035 let Some(cmd) = extract_json_field(&input, "command") else {
1036 return;
1037 };
1038
1039 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1040 emit_rewrite(&rewritten);
1041 }
1042}
1043
1044pub fn handle_rewrite_inline() {
1049 if is_disabled() {
1050 return;
1051 }
1052 let binary = resolve_binary_native();
1053 let args: Vec<String> = std::env::args().collect();
1054 if args.len() < 4 {
1056 return;
1057 }
1058 let cmd = args[3..].join(" ");
1059
1060 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1061 print!("{rewritten}");
1062 return;
1063 }
1064
1065 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1066 print!("{cmd}");
1067 return;
1068 }
1069
1070 print!("{cmd}");
1071}
1072
1073fn resolve_binary() -> String {
1074 let path = crate::core::portable_binary::resolve_portable_binary();
1075 crate::hooks::to_bash_compatible_path(&path)
1076}
1077
1078fn resolve_binary_native() -> String {
1079 crate::core::portable_binary::resolve_portable_binary()
1080}
1081
1082fn extract_json_field(input: &str, field: &str) -> Option<String> {
1083 let key = format!("\"{field}\":");
1084 let key_pos = input.find(&key)?;
1085 let after_colon = &input[key_pos + key.len()..];
1086 let trimmed = after_colon.trim_start();
1087 if !trimmed.starts_with('"') {
1088 return None;
1089 }
1090 let rest = &trimmed[1..];
1091 let bytes = rest.as_bytes();
1092 let mut end = 0;
1093 while end < bytes.len() {
1094 if bytes[end] == b'\\' && end + 1 < bytes.len() {
1095 end += 2;
1096 continue;
1097 }
1098 if bytes[end] == b'"' {
1099 break;
1100 }
1101 end += 1;
1102 }
1103 if end >= bytes.len() {
1104 return None;
1105 }
1106 let raw = &rest[..end];
1107 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 #[test]
1115 fn is_rewritable_basic() {
1116 assert!(is_rewritable("git status"));
1117 assert!(is_rewritable("cargo test --lib"));
1118 assert!(is_rewritable("npm run build"));
1119 assert!(!is_rewritable("echo hello"));
1120 assert!(!is_rewritable("cd src"));
1121 assert!(!is_rewritable("cat file.rs"));
1122 }
1123
1124 #[test]
1125 fn file_read_rewrite_cat() {
1126 let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
1127 assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
1128 }
1129
1130 #[test]
1131 fn file_read_rewrite_head_with_n() {
1132 let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
1133 assert_eq!(
1134 r,
1135 Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
1136 );
1137 }
1138
1139 #[test]
1140 fn file_read_rewrite_head_short() {
1141 let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
1142 assert_eq!(
1143 r,
1144 Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
1145 );
1146 }
1147
1148 #[test]
1149 fn file_read_rewrite_tail() {
1150 let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
1151 assert_eq!(
1152 r,
1153 Some("lean-ctx read src/main.rs -m lines:-10".to_string())
1154 );
1155 }
1156
1157 #[test]
1158 fn file_read_rewrite_not_git() {
1159 assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
1160 }
1161
1162 #[test]
1163 fn parse_head_tail_args_basic() {
1164 let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
1165 assert_eq!(n, Some(20));
1166 assert_eq!(path, Some("file.rs"));
1167 }
1168
1169 #[test]
1170 fn parse_head_tail_args_combined() {
1171 let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
1172 assert_eq!(n, Some(20));
1173 assert_eq!(path, Some("file.rs"));
1174 }
1175
1176 #[test]
1177 fn parse_head_tail_args_short_flag() {
1178 let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
1179 assert_eq!(n, Some(50));
1180 assert_eq!(path, Some("file.rs"));
1181 }
1182
1183 #[test]
1184 fn should_passthrough_rules_files() {
1185 assert!(should_passthrough("/home/user/.cursorrules"));
1186 assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
1187 assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
1188 assert!(should_passthrough("/project/SKILL.md"));
1189 assert!(should_passthrough("/project/AGENTS.md"));
1190 assert!(should_passthrough("/project/icon.png"));
1191 assert!(!should_passthrough("/project/src/main.rs"));
1192 assert!(!should_passthrough("/project/src/lib.ts"));
1193 }
1194
1195 #[test]
1196 fn wrap_single() {
1197 let r = wrap_single_command("git status", "lean-ctx");
1198 assert_eq!(r, "lean-ctx -c 'git status'");
1199 }
1200
1201 #[test]
1202 fn wrap_with_quotes() {
1203 let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
1204 assert_eq!(r, r#"lean-ctx -c 'curl -H "Auth" https://api.com'"#);
1205 }
1206
1207 #[test]
1208 fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
1209 assert_eq!(
1210 rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
1211 None
1212 );
1213 }
1214
1215 #[test]
1216 fn rewrite_candidate_wraps_single_command() {
1217 assert_eq!(
1218 rewrite_candidate("git status", "lean-ctx"),
1219 Some("lean-ctx -c 'git status'".to_string())
1220 );
1221 }
1222
1223 #[test]
1224 fn rewrite_candidate_passes_through_heredoc() {
1225 assert_eq!(
1226 rewrite_candidate(
1227 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1228 "lean-ctx"
1229 ),
1230 None
1231 );
1232 }
1233
1234 #[test]
1235 fn rewrite_candidate_passes_through_heredoc_compound() {
1236 assert_eq!(
1237 rewrite_candidate(
1238 "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
1239 "lean-ctx"
1240 ),
1241 None
1242 );
1243 }
1244
1245 #[test]
1246 fn codex_reroute_message_includes_exact_rewritten_command() {
1247 let message = codex_reroute_message("lean-ctx -c 'git status'");
1248 assert_eq!(
1249 message,
1250 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
1251 );
1252 }
1253
1254 #[test]
1255 fn compound_rewrite_and_chain() {
1256 let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
1257 assert_eq!(
1258 result,
1259 Some("cd src && lean-ctx -c 'git status' && echo done".into())
1260 );
1261 }
1262
1263 #[test]
1264 fn compound_rewrite_pipe() {
1265 let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
1266 assert_eq!(
1267 result,
1268 Some("lean-ctx -c 'git log --oneline' | head -5".into())
1269 );
1270 }
1271
1272 #[test]
1273 fn compound_rewrite_no_match() {
1274 let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
1275 assert_eq!(result, None);
1276 }
1277
1278 #[test]
1279 fn compound_rewrite_multiple_rewritable() {
1280 let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
1281 assert_eq!(
1282 result,
1283 Some(
1284 "lean-ctx -c 'git add .' && lean-ctx -c 'cargo test' && lean-ctx -c 'npm run lint'"
1285 .into()
1286 )
1287 );
1288 }
1289
1290 #[test]
1291 fn compound_rewrite_semicolons() {
1292 let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
1293 assert_eq!(
1294 result,
1295 Some("lean-ctx -c 'git add .' ; lean-ctx -c 'git commit -m '\\''fix'\\'''".into())
1296 );
1297 }
1298
1299 #[test]
1300 fn compound_rewrite_or_chain() {
1301 let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
1302 assert_eq!(result, Some("lean-ctx -c 'git pull' || echo failed".into()));
1303 }
1304
1305 #[test]
1306 fn compound_skips_already_rewritten() {
1307 let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
1308 assert_eq!(
1309 result,
1310 Some("lean-ctx -c git status && lean-ctx -c 'git diff'".into())
1311 );
1312 }
1313
1314 #[test]
1315 fn single_command_not_compound() {
1316 let result = build_rewrite_compound("git status", "lean-ctx");
1317 assert_eq!(result, None);
1318 }
1319
1320 #[test]
1321 fn extract_field_works() {
1322 let input = r#"{"tool_name":"Bash","command":"git status"}"#;
1323 assert_eq!(
1324 extract_json_field(input, "tool_name"),
1325 Some("Bash".to_string())
1326 );
1327 assert_eq!(
1328 extract_json_field(input, "command"),
1329 Some("git status".to_string())
1330 );
1331 }
1332
1333 #[test]
1334 fn extract_field_with_spaces_after_colon() {
1335 let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
1336 assert_eq!(
1337 extract_json_field(input, "tool_name"),
1338 Some("Bash".to_string())
1339 );
1340 assert_eq!(
1341 extract_json_field(input, "command"),
1342 Some("git status".to_string())
1343 );
1344 }
1345
1346 #[test]
1347 fn extract_field_pretty_printed() {
1348 let input = "{\n \"tool_name\": \"Bash\",\n \"tool_input\": {\n \"command\": \"npm test\"\n }\n}";
1349 assert_eq!(
1350 extract_json_field(input, "tool_name"),
1351 Some("Bash".to_string())
1352 );
1353 assert_eq!(
1354 extract_json_field(input, "command"),
1355 Some("npm test".to_string())
1356 );
1357 }
1358
1359 #[test]
1360 fn extract_field_handles_escaped_quotes() {
1361 let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
1362 assert_eq!(
1363 extract_json_field(input, "command"),
1364 Some(r#"grep -r "TODO" src/"#.to_string())
1365 );
1366 }
1367
1368 #[test]
1369 fn extract_field_handles_escaped_backslash() {
1370 let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
1371 assert_eq!(
1372 extract_json_field(input, "command"),
1373 Some(r#"echo \"hello\""#.to_string())
1374 );
1375 }
1376
1377 #[test]
1378 fn extract_field_handles_complex_curl() {
1379 let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
1380 assert_eq!(
1381 extract_json_field(input, "command"),
1382 Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
1383 );
1384 }
1385
1386 #[test]
1387 fn to_bash_compatible_path_windows_drive() {
1388 let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1389 assert_eq!(p, "/e/packages/lean-ctx.exe");
1390 }
1391
1392 #[test]
1393 fn to_bash_compatible_path_backslashes() {
1394 let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
1395 assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
1396 }
1397
1398 #[test]
1399 fn to_bash_compatible_path_unix_unchanged() {
1400 let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
1401 assert_eq!(p, "/usr/local/bin/lean-ctx");
1402 }
1403
1404 #[test]
1405 fn to_bash_compatible_path_msys2_unchanged() {
1406 let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
1407 assert_eq!(p, "/e/packages/lean-ctx.exe");
1408 }
1409
1410 #[test]
1411 fn wrap_command_with_bash_path() {
1412 let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1413 let result = wrap_single_command("git status", &binary);
1414 assert!(
1415 !result.contains('\\'),
1416 "wrapped command must not contain backslashes, got: {result}"
1417 );
1418 assert!(
1419 result.starts_with("/e/packages/lean-ctx.exe"),
1420 "must use bash-compatible path, got: {result}"
1421 );
1422 }
1423
1424 #[test]
1425 fn wrap_single_command_em_dash() {
1426 let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
1427 assert_eq!(r, "lean-ctx -c 'gh --comment \"closing — see #407\"'");
1428 }
1429
1430 #[test]
1431 fn wrap_single_command_dollar_sign() {
1432 let r = wrap_single_command("echo $HOME", "lean-ctx");
1433 assert_eq!(r, "lean-ctx -c 'echo $HOME'");
1434 }
1435
1436 #[test]
1437 fn wrap_single_command_backticks() {
1438 let r = wrap_single_command("echo `date`", "lean-ctx");
1439 assert_eq!(r, "lean-ctx -c 'echo `date`'");
1440 }
1441
1442 #[test]
1443 fn wrap_single_command_nested_single_quotes() {
1444 let r = wrap_single_command("echo 'hello world'", "lean-ctx");
1445 assert_eq!(r, r"lean-ctx -c 'echo '\''hello world'\'''");
1446 }
1447
1448 #[test]
1449 fn wrap_single_command_exclamation_mark() {
1450 let r = wrap_single_command("echo hello!", "lean-ctx");
1451 assert_eq!(r, "lean-ctx -c 'echo hello!'");
1452 }
1453
1454 #[test]
1455 fn wrap_single_command_find_with_many_excludes() {
1456 let r = wrap_single_command(
1457 "find . -not -path ./node_modules -not -path ./.git -not -path ./dist",
1458 "lean-ctx",
1459 );
1460 assert_eq!(
1461 r,
1462 "lean-ctx -c 'find . -not -path ./node_modules -not -path ./.git -not -path ./dist'"
1463 );
1464 }
1465}