Skip to main content

lean_ctx/hook_handlers/
mod.rs

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
50/// Mark this process as a hook child so the daemon-client never auto-starts
51/// the daemon from inside a hook (which would create zombie processes).
52pub fn mark_hook_environment() {
53    // SAFETY: called once at hook-process startup (CLI dispatch), before any
54    // threads that read the environment are spawned.
55    unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
56}
57
58/// Arms a watchdog that force-exits the process after the given duration.
59/// Prevents hook processes from becoming zombies when stdin pipes break or
60/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
61/// (to avoid orphan zombies), a simple exit(1) suffices.
62pub 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
73/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
74fn 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        // Cursor hook output format
112        "permission": "allow",
113        "updated_input": updated_input,
114        // Claude Code / CodeBuddy hook output format (extra fields are ignored by other hosts)
115        "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
190/// Human-readable reason a shell command was left to the native tool. Mirrors
191/// the `None` branches of [`rewrite_candidate`] so #520's debug log can explain
192/// *why* a call fell back to native instead of routing through lean-ctx.
193fn 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    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
223    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
224    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
251/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
252/// Only rewrites simple single-file reads within the project scope.
253fn 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    // Compound commands (pipes, chains) should not be rewritten as file reads.
259    if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
260        return None;
261    }
262
263    // Shell redirections indicate complex usage — don't rewrite.
264    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
309/// Returns true if the path clearly points outside the current project.
310/// Paths starting with `~`, `$`, or absolute paths that don't resolve
311/// within the working directory should not be intercepted.
312fn is_outside_project_path(path: &str) -> bool {
313    let trimmed = path.trim();
314
315    // Home-relative paths are always outside the project
316    if trimmed.starts_with('~') {
317        return true;
318    }
319
320    // Environment variable expansion — too complex, pass through
321    if trimmed.starts_with('$') {
322        return true;
323    }
324
325    // /proc, /sys, /dev, /tmp, /var — system paths
326    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    // Absolute paths: only pass through if they clearly point outside.
336    // We can't know the project root here (hooks are stateless), but we can
337    // detect common external patterns.
338    if trimmed.starts_with('/') {
339        // Home directory paths (e.g. /Users/*/Library, /home/*/.config)
340        if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
341            return true;
342        }
343        // lean-ctx's own data directories
344        if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
345            return true;
346        }
347    }
348
349    false
350}
351
352/// Rewrites `rg <pattern> [path]` to `lean-ctx grep <pattern> [path]` for simple forms.
353fn 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
372/// Rewrites simple `ls [path]` to `lean-ctx ls [path]`.
373fn 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
385/// Tokenize a shell command respecting single/double quotes and backslash escapes.
386pub 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
416/// Quote a path/arg for shell if it contains spaces or special chars.
417pub 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
500/// Redirect Read through lean-ctx for compression + caching.
501/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
502/// prevents daemon auto-start. The subprocess uses the fast local-only path.
503fn 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
579/// Redirect Grep through lean-ctx for compressed results.
580fn 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
654/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
655/// Kills the child if it exceeds the timeout to prevent orphan processes.
656fn 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
806/// Emit SessionStart guidance through Codex's documented hidden-context channel.
807///
808/// Codex's hook contract (<https://developers.openai.com/codex/hooks>) accepts JSON
809/// on stdout with `hookSpecificOutput.additionalContext`, which is injected as
810/// model-visible developer context rather than surfaced to the user as plain text
811/// (#368). Plain stdout text is also added as developer context today, but only the
812/// JSON form is the documented additional-context channel; aligning with it
813/// future-proofs the hook for Codex's TUI-visibility fix (openai/codex#16933) and
814/// matches how the dedicated rules-injection path already emits context.
815pub(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    // Dedicated rules-injection mode (#343): the `hook observe` SessionStart hook
837    // injects the full rules summary as additionalContext, so stay silent here to
838    // avoid double-injecting on Codex (which fires both hooks on SessionStart).
839    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
847/// Copilot-specific PreToolUse handler.
848/// VS Code Copilot Chat uses the same hook format as Claude Code.
849/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
850pub 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
881/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
882/// The command is passed as positional arguments, not via stdin JSON.
883pub 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    // args: [binary, "hook", "rewrite-inline", ...command parts]
890    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
908/// Resolve the lean-ctx executable path for hook command emission and
909/// subprocess spawning. Always the **native** OS path: the MSYS/Git-Bash
910/// `/c/...` form breaks `CreateProcess` on Windows and cannot be run by
911/// PowerShell or cmd (#518). Native `C:/...` runs in PowerShell, cmd *and*
912/// Git Bash, so it is the correct universal form for executed commands.
913/// (MSYS `/c/...` is only needed for bash *source* lines — see `cli::shell_init`.)
914fn 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}