1use crate::compound_lexer;
2use crate::core::debug_log::{self, Route};
3use crate::rewrite_registry;
4use std::io::Read;
5use std::sync::mpsc;
6use std::time::Duration;
7
8const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
9mod observe;
10pub use observe::*;
11#[cfg(test)]
12mod tests;
13
14fn is_disabled() -> bool {
15 std::env::var("LEAN_CTX_DISABLED").is_ok()
16}
17
18fn is_harden_active() -> bool {
19 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
20}
21
22fn is_shadow_mode_active() -> bool {
23 if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
24 return true;
25 }
26 crate::core::config::Config::load().shadow_mode
27}
28
29fn log_shadow_intercept(tool: &str, detail: &str) {
30 if !is_shadow_mode_active() {
31 return;
32 }
33 let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
34 return;
35 };
36 let log_path = data_dir.join("shadow.log");
37 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
38 let line = format!("[{ts}] intercepted {tool}: {detail}\n");
39 let _ = std::fs::OpenOptions::new()
40 .create(true)
41 .append(true)
42 .open(log_path)
43 .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
44}
45
46fn is_quiet() -> bool {
47 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
48}
49
50pub fn mark_hook_environment() {
53 unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
56}
57
58pub fn arm_watchdog(timeout: Duration) {
63 std::thread::spawn(move || {
64 std::thread::sleep(timeout);
65 eprintln!(
66 "[lean-ctx hook] watchdog timeout after {}s — force exit",
67 timeout.as_secs()
68 );
69 std::process::exit(1);
70 });
71}
72
73fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
75 let (tx, rx) = mpsc::channel();
76 std::thread::spawn(move || {
77 let mut buf = String::new();
78 let result = std::io::stdin().read_to_string(&mut buf);
79 let _ = tx.send(result.ok().map(|_| buf));
80 });
81 match rx.recv_timeout(timeout) {
82 Ok(Some(s)) if !s.is_empty() => Some(s),
83 _ => None,
84 }
85}
86
87fn build_dual_allow_output() -> String {
88 serde_json::json!({
89 "permission": "allow",
90 "hookSpecificOutput": {
91 "hookEventName": "PreToolUse",
92 "permissionDecision": "allow"
93 }
94 })
95 .to_string()
96}
97
98fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
99 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
100 let mut m = obj.clone();
101 m.insert(
102 "command".to_string(),
103 serde_json::Value::String(rewritten.to_string()),
104 );
105 serde_json::Value::Object(m)
106 } else {
107 serde_json::json!({ "command": rewritten })
108 };
109
110 serde_json::json!({
111 "permission": "allow",
113 "updated_input": updated_input,
114 "hookSpecificOutput": {
116 "hookEventName": "PreToolUse",
117 "permissionDecision": "allow",
118 "updatedInput": {
119 "command": rewritten
120 }
121 }
122 })
123 .to_string()
124}
125
126pub fn handle_rewrite() {
127 let allow = build_dual_allow_output();
128 if is_disabled() {
129 print!("{allow}");
130 return;
131 }
132 let binary = resolve_binary();
133 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
134 print!("{allow}");
135 return;
136 };
137
138 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
139 tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
140 print!("{allow}");
141 return;
142 };
143
144 let tool = v.get("tool_name").and_then(|t| t.as_str());
145 let Some(tool_name) = tool else {
146 print!("{allow}");
147 return;
148 };
149
150 let is_shell_tool = matches!(
151 tool_name,
152 "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
153 );
154 if !is_shell_tool {
155 print!("{allow}");
156 return;
157 }
158
159 let tool_input = v.get("tool_input");
160 let Some(cmd) = tool_input
161 .and_then(|ti| ti.get("command"))
162 .and_then(|c| c.as_str())
163 .or_else(|| v.get("command").and_then(|c| c.as_str()))
164 else {
165 print!("{allow}");
166 return;
167 };
168
169 if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
170 debug_log::log_hook_decision(
171 "rewrite",
172 tool_name,
173 Route::LeanCtx,
174 cmd,
175 "rewritable command",
176 );
177 print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
178 } else {
179 debug_log::log_hook_decision(
180 "rewrite",
181 tool_name,
182 Route::Native,
183 cmd,
184 rewrite_skip_reason(cmd),
185 );
186 print!("{allow}");
187 }
188}
189
190fn rewrite_skip_reason(cmd: &str) -> &'static str {
194 if cmd.starts_with("lean-ctx ") {
195 "already a lean-ctx command"
196 } else if cmd.contains("<<") {
197 "heredoc cannot be rewritten safely"
198 } else {
199 "not a known read/search/list command"
200 }
201}
202
203fn is_rewritable(cmd: &str) -> bool {
204 rewrite_registry::is_rewritable_command(cmd)
205}
206
207fn wrap_single_command(cmd: &str, binary: &str) -> String {
208 if cfg!(windows) {
209 let escaped = cmd.replace('"', "\\\"");
210 format!("{binary} -c \"{escaped}\"")
211 } else {
212 let shell_escaped = cmd.replace('\'', "'\\''");
213 format!("{binary} -c '{shell_escaped}'")
214 }
215}
216
217fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
218 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
219 return None;
220 }
221
222 if cmd.contains("<<") {
225 return None;
226 }
227
228 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
229 return Some(rewritten);
230 }
231
232 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
233 return Some(rewritten);
234 }
235
236 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
237 return Some(rewritten);
238 }
239
240 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
241 return Some(rewritten);
242 }
243
244 if is_rewritable(cmd) {
245 return Some(wrap_single_command(cmd, binary));
246 }
247
248 None
249}
250
251fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
254 if !rewrite_registry::is_file_read_command(cmd) {
255 return None;
256 }
257
258 if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
260 return None;
261 }
262
263 if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
265 return None;
266 }
267
268 let parts = shell_tokenize(cmd);
269 if parts.len() < 2 {
270 return None;
271 }
272
273 match parts[0].as_str() {
274 "cat" => {
275 let path = parts[1..].join(" ");
276 if is_outside_project_path(&path) {
277 return None;
278 }
279 Some(format!("{binary} read {}", shell_quote(&path)))
280 }
281 "head" => {
282 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
283 let (n, path) = parse_head_tail_args(&refs);
284 let path = path?;
285 if is_outside_project_path(path) {
286 return None;
287 }
288 let qp = shell_quote(path);
289 match n {
290 Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
291 None => Some(format!("{binary} read {qp} -m lines:1-10")),
292 }
293 }
294 "tail" => {
295 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
296 let (n, path) = parse_head_tail_args(&refs);
297 let path = path?;
298 if is_outside_project_path(path) {
299 return None;
300 }
301 let qp = shell_quote(path);
302 let lines = n.unwrap_or(10);
303 Some(format!("{binary} read {qp} -m lines:-{lines}"))
304 }
305 _ => None,
306 }
307}
308
309fn is_outside_project_path(path: &str) -> bool {
313 let trimmed = path.trim();
314
315 if trimmed.starts_with('~') {
317 return true;
318 }
319
320 if trimmed.starts_with('$') {
322 return true;
323 }
324
325 if trimmed.starts_with("/proc/")
327 || trimmed.starts_with("/sys/")
328 || trimmed.starts_with("/dev/")
329 || trimmed.starts_with("/tmp/")
330 || trimmed.starts_with("/var/")
331 {
332 return true;
333 }
334
335 if trimmed.starts_with('/') {
339 if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
341 return true;
342 }
343 if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
345 return true;
346 }
347 }
348
349 false
350}
351
352fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
354 let parts = shell_tokenize(cmd);
355 if parts.first().map(String::as_str) != Some("rg") {
356 return None;
357 }
358 if parts.len() < 2 || parts.len() > 3 {
359 return None;
360 }
361 if parts[1].starts_with('-') {
362 return None;
363 }
364 let pattern = &parts[1];
365 match parts.get(2) {
366 Some(p) if p.starts_with('-') => None,
367 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
368 None => Some(format!("{binary} grep {pattern}")),
369 }
370}
371
372fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
374 let parts = shell_tokenize(cmd);
375 if parts.first().map(String::as_str) != Some("ls") {
376 return None;
377 }
378 match parts.len() {
379 1 => Some(format!("{binary} ls")),
380 2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", shell_quote(&parts[1]))),
381 _ => None,
382 }
383}
384
385pub fn shell_tokenize(input: &str) -> Vec<String> {
387 let mut tokens = Vec::new();
388 let mut current = String::new();
389 let mut chars = input.chars().peekable();
390 let mut in_single = false;
391 let mut in_double = false;
392
393 while let Some(c) = chars.next() {
394 match c {
395 '\'' if !in_double => in_single = !in_single,
396 '"' if !in_single => in_double = !in_double,
397 '\\' if !in_single => {
398 if let Some(next) = chars.next() {
399 current.push(next);
400 }
401 }
402 c if c.is_whitespace() && !in_single && !in_double => {
403 if !current.is_empty() {
404 tokens.push(std::mem::take(&mut current));
405 }
406 }
407 _ => current.push(c),
408 }
409 }
410 if !current.is_empty() {
411 tokens.push(current);
412 }
413 tokens
414}
415
416pub fn shell_quote(s: &str) -> String {
418 if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
419 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
420 } else {
421 s.to_string()
422 }
423}
424
425fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
426 let mut n: Option<usize> = None;
427 let mut path: Option<&str> = None;
428
429 let mut i = 0;
430 while i < args.len() {
431 if args[i] == "-n" && i + 1 < args.len() {
432 n = args[i + 1].parse().ok();
433 i += 2;
434 } else if let Some(num) = args[i].strip_prefix("-n") {
435 n = num.parse().ok();
436 i += 1;
437 } else if args[i].starts_with('-') && args[i].len() > 1 {
438 if let Ok(num) = args[i][1..].parse::<usize>() {
439 n = Some(num);
440 }
441 i += 1;
442 } else {
443 path = Some(args[i]);
444 i += 1;
445 }
446 }
447
448 (n, path)
449}
450
451fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
452 compound_lexer::rewrite_compound(cmd, |segment| {
453 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
454 return None;
455 }
456 if is_rewritable(segment) {
457 Some(wrap_single_command(segment, binary))
458 } else {
459 None
460 }
461 })
462}
463
464fn emit_rewrite(rewritten: &str) {
465 let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
466 print!(
467 "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
468 );
469}
470
471pub fn handle_redirect() {
472 let allow = build_dual_allow_output();
473 if is_disabled() {
474 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
475 print!("{allow}");
476 return;
477 }
478
479 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
480 print!("{allow}");
481 return;
482 };
483
484 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
485 tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
486 print!("{allow}");
487 return;
488 };
489
490 let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
491 let tool_input = v.get("tool_input");
492
493 match tool_name {
494 "Read" | "read" | "read_file" => redirect_read(tool_input),
495 "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
496 _ => print!("{allow}"),
497 }
498}
499
500fn redirect_read(tool_input: Option<&serde_json::Value>) {
504 let path = tool_input
505 .and_then(|ti| ti.get("path"))
506 .and_then(|p| p.as_str())
507 .unwrap_or("");
508
509 if path.is_empty() {
510 debug_log::log_hook_decision(
511 "redirect",
512 "Read",
513 Route::Native,
514 "<none>",
515 "no path in tool input",
516 );
517 print!("{}", build_dual_allow_output());
518 return;
519 }
520 if should_passthrough(path) {
521 debug_log::log_hook_decision(
522 "redirect",
523 "Read",
524 Route::Native,
525 path,
526 "passthrough path (sensitive/binary/excluded)",
527 );
528 print!("{}", build_dual_allow_output());
529 return;
530 }
531
532 let shadow = is_shadow_mode_active();
533 if is_harden_active() || shadow {
534 tracing::info!(
535 "[hook redirect] {} active, redirecting Read through lean-ctx",
536 if shadow { "shadow mode" } else { "harden mode" }
537 );
538 }
539
540 let binary = resolve_binary();
541 let temp_path = redirect_temp_path(path);
542
543 if let Some(mut output) =
544 run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT)
545 {
546 if shadow {
547 let header = format!(
548 "[shadow-mode: Read intercepted → ctx_read(\"{path}\", \"full\"). Use ctx_read directly for better performance.]\n\n"
549 );
550 let mut prefixed = header.into_bytes();
551 prefixed.append(&mut output);
552 output = prefixed;
553 }
554 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
555 let temp_str = temp_path.to_str().unwrap_or("");
556 debug_log::log_hook_decision(
557 "redirect",
558 "Read",
559 Route::LeanCtx,
560 path,
561 "redirected to ctx_read",
562 );
563 print!("{}", build_redirect_output(tool_input, "path", temp_str));
564 log_shadow_intercept("Read", path);
565 return;
566 }
567 }
568
569 debug_log::log_hook_decision(
570 "redirect",
571 "Read",
572 Route::Native,
573 path,
574 "lean-ctx read produced no output",
575 );
576 print!("{}", build_dual_allow_output());
577}
578
579fn redirect_grep(tool_input: Option<&serde_json::Value>) {
581 let pattern = tool_input
582 .and_then(|ti| ti.get("pattern"))
583 .and_then(|p| p.as_str())
584 .unwrap_or("");
585 let search_path = tool_input
586 .and_then(|ti| ti.get("path"))
587 .and_then(|p| p.as_str())
588 .unwrap_or(".");
589
590 if pattern.is_empty() {
591 debug_log::log_hook_decision(
592 "redirect",
593 "Grep",
594 Route::Native,
595 "<none>",
596 "no pattern in tool input",
597 );
598 print!("{}", build_dual_allow_output());
599 return;
600 }
601
602 let shadow = is_shadow_mode_active();
603 if is_harden_active() || shadow {
604 tracing::info!(
605 "[hook redirect] {} active, redirecting Grep through lean-ctx",
606 if shadow { "shadow mode" } else { "harden mode" }
607 );
608 }
609
610 let binary = resolve_binary();
611 let key = format!("grep:{pattern}:{search_path}");
612 let temp_path = redirect_temp_path(&key);
613
614 if let Some(mut output) = run_with_timeout(
615 &binary,
616 &["grep", pattern, search_path],
617 REDIRECT_SUBPROCESS_TIMEOUT,
618 ) {
619 if shadow {
620 let header = format!(
621 "[shadow-mode: Grep intercepted → ctx_search(\"{pattern}\", \"{search_path}\"). Use ctx_search directly for better performance.]\n\n"
622 );
623 let mut prefixed = header.into_bytes();
624 prefixed.append(&mut output);
625 output = prefixed;
626 }
627 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
628 let temp_str = temp_path.to_str().unwrap_or("");
629 debug_log::log_hook_decision(
630 "redirect",
631 "Grep",
632 Route::LeanCtx,
633 &format!("{pattern} in {search_path}"),
634 "redirected to ctx_search",
635 );
636 print!("{}", build_redirect_output(tool_input, "path", temp_str));
637 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
638 return;
639 }
640 }
641
642 debug_log::log_hook_decision(
643 "redirect",
644 "Grep",
645 Route::Native,
646 &format!("{pattern} in {search_path}"),
647 "lean-ctx grep produced no output",
648 );
649 print!("{}", build_dual_allow_output());
650}
651
652const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
653
654fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
657 let mut child = std::process::Command::new(binary)
658 .args(args)
659 .stdout(std::process::Stdio::piped())
660 .stderr(std::process::Stdio::null())
661 .spawn()
662 .ok()?;
663
664 let deadline = std::time::Instant::now() + timeout;
665 loop {
666 match child.try_wait() {
667 Ok(Some(status)) if status.success() => {
668 let mut stdout = Vec::new();
669 if let Some(mut out) = child.stdout.take() {
670 let _ = out.read_to_end(&mut stdout);
671 }
672 return if stdout.is_empty() {
673 None
674 } else {
675 Some(stdout)
676 };
677 }
678 Ok(Some(_)) | Err(_) => return None,
679 Ok(None) => {
680 if std::time::Instant::now() > deadline {
681 let _ = child.kill();
682 let _ = child.wait();
683 return None;
684 }
685 std::thread::sleep(Duration::from_millis(10));
686 }
687 }
688 }
689}
690
691fn redirect_temp_path(key: &str) -> std::path::PathBuf {
692 use std::collections::hash_map::DefaultHasher;
693 use std::hash::{Hash, Hasher};
694
695 let mut hasher = DefaultHasher::new();
696 key.hash(&mut hasher);
697 std::process::id().hash(&mut hasher);
698 let hash = hasher.finish();
699
700 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
701 let _ = std::fs::create_dir_all(&temp_dir);
702 #[cfg(unix)]
703 {
704 use std::os::unix::fs::PermissionsExt;
705 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
706 }
707 temp_dir.join(format!("{hash:016x}.lctx"))
708}
709
710fn build_redirect_output(
711 tool_input: Option<&serde_json::Value>,
712 field: &str,
713 temp_path: &str,
714) -> String {
715 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
716 let mut m = obj.clone();
717 m.insert(
718 field.to_string(),
719 serde_json::Value::String(temp_path.to_string()),
720 );
721 serde_json::Value::Object(m)
722 } else {
723 serde_json::json!({ field: temp_path })
724 };
725
726 serde_json::json!({
727 "permission": "allow",
728 "updated_input": updated_input,
729 "hookSpecificOutput": {
730 "hookEventName": "PreToolUse",
731 "permissionDecision": "allow",
732 "updatedInput": { field: temp_path }
733 }
734 })
735 .to_string()
736}
737
738const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
739 ".cursorrules",
740 ".cursor/rules",
741 ".cursor/hooks",
742 "skill.md",
743 "agents.md",
744 ".env",
745 "hooks.json",
746 "node_modules",
747];
748
749const PASSTHROUGH_EXTENSIONS: &[&str] = &[
750 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
751];
752
753fn should_passthrough(path: &str) -> bool {
754 let p = path.to_lowercase();
755
756 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
757 return true;
758 }
759
760 std::path::Path::new(&p)
761 .extension()
762 .and_then(|ext| ext.to_str())
763 .is_some_and(|ext| {
764 PASSTHROUGH_EXTENSIONS
765 .iter()
766 .any(|e| ext.eq_ignore_ascii_case(e))
767 })
768}
769
770fn codex_rewrite_output(rewritten: &str) -> String {
771 serde_json::json!({
772 "hookSpecificOutput": {
773 "hookEventName": "PreToolUse",
774 "permissionDecision": "allow",
775 "updatedInput": {
776 "command": rewritten
777 }
778 }
779 })
780 .to_string()
781}
782
783pub fn handle_codex_pretooluse() {
784 if is_disabled() {
785 return;
786 }
787 let binary = resolve_binary();
788 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
789 return;
790 };
791
792 let tool = extract_json_field(&input, "tool_name");
793 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
794 return;
795 }
796
797 let Some(cmd) = extract_json_field(&input, "command") else {
798 return;
799 };
800
801 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
802 print!("{}", codex_rewrite_output(&rewritten));
803 }
804}
805
806pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
816 serde_json::json!({
817 "hookSpecificOutput": {
818 "hookEventName": "SessionStart",
819 "additionalContext": additional_context,
820 }
821 })
822 .to_string()
823}
824
825pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
826 println!(
827 "{}",
828 session_start_additional_context_json(additional_context)
829 );
830}
831
832pub fn handle_codex_session_start() {
833 if is_quiet() {
834 return;
835 }
836 if crate::core::config::Config::load().dedicated_session_context_active() {
840 return;
841 }
842 emit_session_start_additional_context(
843 "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.",
844 );
845}
846
847pub fn handle_copilot() {
851 if is_disabled() {
852 return;
853 }
854 let binary = resolve_binary();
855 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
856 return;
857 };
858
859 let tool = extract_json_field(&input, "tool_name");
860 let Some(tool_name) = tool.as_deref() else {
861 return;
862 };
863
864 let is_shell_tool = matches!(
865 tool_name,
866 "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
867 );
868 if !is_shell_tool {
869 return;
870 }
871
872 let Some(cmd) = extract_json_field(&input, "command") else {
873 return;
874 };
875
876 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
877 emit_rewrite(&rewritten);
878 }
879}
880
881pub fn handle_rewrite_inline() {
884 if is_disabled() {
885 return;
886 }
887 let binary = resolve_binary();
888 let args: Vec<String> = std::env::args().collect();
889 if args.len() < 4 {
891 return;
892 }
893 let cmd = args[3..].join(" ");
894
895 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
896 print!("{rewritten}");
897 return;
898 }
899
900 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
901 print!("{cmd}");
902 return;
903 }
904
905 print!("{cmd}");
906}
907
908fn resolve_binary() -> String {
915 crate::core::portable_binary::resolve_portable_binary()
916}
917
918fn extract_json_field(input: &str, field: &str) -> Option<String> {
919 let key = format!("\"{field}\":");
920 let key_pos = input.find(&key)?;
921 let after_colon = &input[key_pos + key.len()..];
922 let trimmed = after_colon.trim_start();
923 if !trimmed.starts_with('"') {
924 return None;
925 }
926 let rest = &trimmed[1..];
927 let bytes = rest.as_bytes();
928 let mut end = 0;
929 while end < bytes.len() {
930 if bytes[end] == b'\\' && end + 1 < bytes.len() {
931 end += 2;
932 continue;
933 }
934 if bytes[end] == b'"' {
935 break;
936 }
937 end += 1;
938 }
939 if end >= bytes.len() {
940 return None;
941 }
942 let raw = &rest[..end];
943 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
944}