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