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;
10mod payload;
11pub use observe::*;
12#[cfg(test)]
13mod tests;
14
15fn is_disabled() -> bool {
16    std::env::var("LEAN_CTX_DISABLED").is_ok()
17}
18
19fn is_harden_active() -> bool {
20    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
21}
22
23fn is_shadow_mode_active() -> bool {
24    if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
25        return true;
26    }
27    crate::core::config::Config::load().shadow_mode
28}
29
30fn log_shadow_intercept(tool: &str, detail: &str) {
31    if !is_shadow_mode_active() {
32        return;
33    }
34    let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
35        return;
36    };
37    let log_path = data_dir.join("shadow.log");
38    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
39    let line = format!("[{ts}] intercepted {tool}: {detail}\n");
40    let _ = std::fs::OpenOptions::new()
41        .create(true)
42        .append(true)
43        .open(log_path)
44        .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
45}
46
47fn is_quiet() -> bool {
48    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
49}
50
51/// Mark this process as a hook child so the daemon-client never auto-starts
52/// the daemon from inside a hook (which would create zombie processes).
53pub fn mark_hook_environment() {
54    // SAFETY: called once at hook-process startup (CLI dispatch), before any
55    // threads that read the environment are spawned.
56    unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
57}
58
59/// Arms a watchdog that force-exits the process after the given duration.
60/// Prevents hook processes from becoming zombies when stdin pipes break or
61/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
62/// (to avoid orphan zombies), a simple exit(1) suffices.
63pub fn arm_watchdog(timeout: Duration) {
64    std::thread::spawn(move || {
65        std::thread::sleep(timeout);
66        eprintln!(
67            "[lean-ctx hook] watchdog timeout after {}s — force exit",
68            timeout.as_secs()
69        );
70        std::process::exit(1);
71    });
72}
73
74/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
75fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
76    let (tx, rx) = mpsc::channel();
77    std::thread::spawn(move || {
78        let mut buf = String::new();
79        let result = std::io::stdin().read_to_string(&mut buf);
80        let _ = tx.send(result.ok().map(|_| buf));
81    });
82    match rx.recv_timeout(timeout) {
83        Ok(Some(s)) if !s.is_empty() => Some(s),
84        _ => None,
85    }
86}
87
88fn build_dual_allow_output() -> String {
89    serde_json::json!({
90        "permission": "allow",
91        "hookSpecificOutput": {
92            "hookEventName": "PreToolUse",
93            "permissionDecision": "allow"
94        }
95    })
96    .to_string()
97}
98
99fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
100    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
101        let mut m = obj.clone();
102        m.insert(
103            "command".to_string(),
104            serde_json::Value::String(rewritten.to_string()),
105        );
106        serde_json::Value::Object(m)
107    } else {
108        serde_json::json!({ "command": rewritten })
109    };
110
111    serde_json::json!({
112        // Cursor hook output format.
113        "permission": "allow",
114        "updated_input": updated_input.clone(),
115        // GitHub Copilot CLI preToolUse format: top-level `permissionDecision`
116        // + `modifiedArgs` (a full substitute-args object). Copilot ignores
117        // `hookSpecificOutput`, so without these fields it runs the command
118        // unmodified even after the camelCase payload parses correctly (#551).
119        "permissionDecision": "allow",
120        "modifiedArgs": updated_input.clone(),
121        // Claude Code / CodeBuddy hook output format (other hosts ignore it).
122        "hookSpecificOutput": {
123            "hookEventName": "PreToolUse",
124            "permissionDecision": "allow",
125            "updatedInput": updated_input
126        }
127    })
128    .to_string()
129}
130
131/// True when a host tool name denotes a shell/terminal command tool.
132///
133/// Copilot CLI exposes `powershell` as a first-class shell tool on Windows
134/// (paired with `bash` per the CLI tool reference); without it Windows shell
135/// calls bypass rewrite (#556). Shared by `handle_rewrite` and `handle_copilot`.
136fn is_shell_tool(tool_name: &str) -> bool {
137    matches!(
138        tool_name,
139        "Bash"
140            | "bash"
141            | "Shell"
142            | "shell"
143            | "runInTerminal"
144            | "run_in_terminal"
145            | "terminal"
146            | "PowerShell"
147            | "powershell"
148            | "pwsh"
149    )
150}
151
152pub fn handle_rewrite() {
153    let allow = build_dual_allow_output();
154    if is_disabled() {
155        print!("{allow}");
156        return;
157    }
158    let binary = resolve_binary();
159    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
160        print!("{allow}");
161        return;
162    };
163
164    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
165        tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
166        print!("{allow}");
167        return;
168    };
169
170    // Resolve across host shapes: Claude/Cursor send snake_case `tool_name` +
171    // `tool_input`; Copilot CLI sends camelCase `toolName` + `toolArgs` (a
172    // JSON-encoded string). Before #551 only the snake_case path was read.
173    let Some(tool_name) = payload::resolve_tool_name(&v) else {
174        print!("{allow}");
175        return;
176    };
177
178    if !is_shell_tool(&tool_name) {
179        print!("{allow}");
180        return;
181    }
182
183    let tool_args = payload::resolve_tool_args(&v);
184    let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
185        print!("{allow}");
186        return;
187    };
188
189    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
190        debug_log::log_hook_decision(
191            "rewrite",
192            &tool_name,
193            Route::LeanCtx,
194            &cmd,
195            "rewritable command",
196        );
197        print!(
198            "{}",
199            build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
200        );
201    } else {
202        debug_log::log_hook_decision(
203            "rewrite",
204            &tool_name,
205            Route::Native,
206            &cmd,
207            rewrite_skip_reason(&cmd),
208        );
209        print!("{allow}");
210    }
211}
212
213/// Human-readable reason a shell command was left to the native tool. Mirrors
214/// the `None` branches of [`rewrite_candidate`] so #520's debug log can explain
215/// *why* a call fell back to native instead of routing through lean-ctx.
216fn rewrite_skip_reason(cmd: &str) -> &'static str {
217    if cmd.starts_with("lean-ctx ") {
218        "already a lean-ctx command"
219    } else if cmd.contains("<<") {
220        "heredoc cannot be rewritten safely"
221    } else {
222        "not a known read/search/list command"
223    }
224}
225
226fn is_rewritable(cmd: &str) -> bool {
227    rewrite_registry::is_rewritable_command(cmd)
228}
229
230fn wrap_single_command(cmd: &str, binary: &str) -> String {
231    if cfg!(windows) {
232        let escaped = cmd.replace('"', "\\\"");
233        format!("{binary} -c \"{escaped}\"")
234    } else {
235        let shell_escaped = cmd.replace('\'', "'\\''");
236        format!("{binary} -c '{shell_escaped}'")
237    }
238}
239
240fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
241    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
242        return None;
243    }
244
245    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
246    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
247    if cmd.contains("<<") {
248        return None;
249    }
250
251    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
252        return Some(rewritten);
253    }
254
255    if let Some(rewritten) = rewrite_search_command(cmd, binary) {
256        return Some(rewritten);
257    }
258
259    if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
260        return Some(rewritten);
261    }
262
263    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
264        return Some(rewritten);
265    }
266
267    if is_rewritable(cmd) {
268        return Some(wrap_single_command(cmd, binary));
269    }
270
271    None
272}
273
274/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
275/// Only rewrites simple single-file reads within the project scope.
276fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
277    // Unix file-read commands come from the central registry; PowerShell-native
278    // cmdlets (Get-Content/gc) are detected here so they are not added to the POSIX
279    // shell-alias/registry surface (#561).
280    if !rewrite_registry::is_file_read_command(cmd) && !is_powershell_file_read(cmd) {
281        return None;
282    }
283
284    // Compound commands (pipes, chains) should not be rewritten as file reads.
285    if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
286        return None;
287    }
288
289    // Shell redirections indicate complex usage — don't rewrite.
290    if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
291        return None;
292    }
293
294    let parts = shell_tokenize(cmd);
295    if parts.len() < 2 {
296        return None;
297    }
298
299    match parts[0].as_str() {
300        "cat" => {
301            let path = parts[1..].join(" ");
302            if is_outside_project_path(&path) {
303                return None;
304            }
305            Some(format!("{binary} read {}", shell_quote(&path)))
306        }
307        "head" => {
308            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
309            let (n, path) = parse_head_tail_args(&refs);
310            let path = path?;
311            if is_outside_project_path(path) {
312                return None;
313            }
314            let qp = shell_quote(path);
315            match n {
316                Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
317                None => Some(format!("{binary} read {qp} -m lines:1-10")),
318            }
319        }
320        "tail" => {
321            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
322            let (n, path) = parse_head_tail_args(&refs);
323            let path = path?;
324            if is_outside_project_path(path) {
325                return None;
326            }
327            let qp = shell_quote(path);
328            let lines = n.unwrap_or(10);
329            Some(format!("{binary} read {qp} -m lines:-{lines}"))
330        }
331        "Get-Content" | "gc" => rewrite_get_content(&parts, binary),
332        _ => None,
333    }
334}
335
336/// True if the command is a PowerShell-native file-read cmdlet (`Get-Content`/`gc`).
337fn is_powershell_file_read(cmd: &str) -> bool {
338    matches!(cmd.split_whitespace().next(), Some("Get-Content" | "gc"))
339}
340
341/// Maps `Get-Content`/`gc` to `lean-ctx read`, honoring `-Path`/`-LiteralPath`, the
342/// positional path, `-TotalCount`/`-Head`/`-First` (first N lines) and `-Tail`/`-Last`
343/// (last N lines). PowerShell parameter names are case-insensitive. Any other flag, a
344/// missing path, multiple files, or both head+tail makes it pass through (conservative,
345/// mirroring the Unix cat/head/tail handling).
346fn rewrite_get_content(parts: &[String], binary: &str) -> Option<String> {
347    let mut path: Option<String> = None;
348    let mut head_n: Option<u64> = None;
349    let mut tail_n: Option<u64> = None;
350    let mut i = 1;
351    while i < parts.len() {
352        if let Some(flag) = parts[i].strip_prefix('-') {
353            let value = parts.get(i + 1);
354            match flag.to_ascii_lowercase().as_str() {
355                "path" | "literalpath" => path = Some(value?.clone()),
356                "totalcount" | "head" | "first" => head_n = Some(value?.parse().ok()?),
357                "tail" | "last" => tail_n = Some(value?.parse().ok()?),
358                _ => return None,
359            }
360            i += 2;
361        } else if path.is_none() {
362            path = Some(parts[i].clone());
363            i += 1;
364        } else {
365            return None;
366        }
367    }
368    let path = path?;
369    if is_outside_project_path(&path) || (head_n.is_some() && tail_n.is_some()) {
370        return None;
371    }
372    let qp = shell_quote(&path);
373    match (head_n, tail_n) {
374        (Some(n), None) => Some(format!("{binary} read {qp} -m lines:1-{n}")),
375        (None, Some(n)) => Some(format!("{binary} read {qp} -m lines:-{n}")),
376        _ => Some(format!("{binary} read {qp}")),
377    }
378}
379
380/// Returns true if the path clearly points outside the current project.
381/// Paths starting with `~`, `$`, or absolute paths that don't resolve
382/// within the working directory should not be intercepted.
383fn is_outside_project_path(path: &str) -> bool {
384    let trimmed = path.trim();
385
386    // Home-relative paths are always outside the project
387    if trimmed.starts_with('~') {
388        return true;
389    }
390
391    // Environment variable expansion — too complex, pass through
392    if trimmed.starts_with('$') {
393        return true;
394    }
395
396    // /proc, /sys, /dev, /tmp, /var — system paths
397    if trimmed.starts_with("/proc/")
398        || trimmed.starts_with("/sys/")
399        || trimmed.starts_with("/dev/")
400        || trimmed.starts_with("/tmp/")
401        || trimmed.starts_with("/var/")
402    {
403        return true;
404    }
405
406    // Absolute paths: only pass through if they clearly point outside.
407    // We can't know the project root here (hooks are stateless), but we can
408    // detect common external patterns.
409    if trimmed.starts_with('/') {
410        // Home directory paths (e.g. /Users/*/Library, /home/*/.config)
411        if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
412            return true;
413        }
414        // lean-ctx's own data directories
415        if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
416            return true;
417        }
418    }
419
420    false
421}
422
423/// Rewrites `rg <pattern> [path]` (and PowerShell `Select-String`/`sls`, #561) to
424/// `lean-ctx grep <pattern> [path]` for simple forms.
425fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
426    let parts = shell_tokenize(cmd);
427    match parts.first().map(String::as_str) {
428        Some("rg") => {
429            if parts.len() < 2 || parts.len() > 3 || parts[1].starts_with('-') {
430                return None;
431            }
432            let pattern = &parts[1];
433            match parts.get(2) {
434                Some(p) if p.starts_with('-') => None,
435                Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
436                None => Some(format!("{binary} grep {pattern}")),
437            }
438        }
439        Some("Select-String" | "sls") => rewrite_select_string(&parts, binary),
440        _ => None,
441    }
442}
443
444/// Maps `Select-String`/`sls` to `lean-ctx grep`, honoring `-Pattern` and
445/// `-Path`/`-LiteralPath` plus the positional `<pattern> [path]` form. Patterns are
446/// quoted (PowerShell patterns often contain spaces). Any other flag, a missing
447/// pattern, or extra operands makes it pass through.
448fn rewrite_select_string(parts: &[String], binary: &str) -> Option<String> {
449    let mut pattern: Option<String> = None;
450    let mut path: Option<String> = None;
451    let mut i = 1;
452    while i < parts.len() {
453        if let Some(flag) = parts[i].strip_prefix('-') {
454            let value = parts.get(i + 1);
455            match flag.to_ascii_lowercase().as_str() {
456                "pattern" => pattern = Some(value?.clone()),
457                "path" | "literalpath" => path = Some(value?.clone()),
458                _ => return None,
459            }
460            i += 2;
461        } else if pattern.is_none() {
462            pattern = Some(parts[i].clone());
463            i += 1;
464        } else if path.is_none() {
465            path = Some(parts[i].clone());
466            i += 1;
467        } else {
468            return None;
469        }
470    }
471    let pattern = shell_quote(&pattern?);
472    match path {
473        Some(p) if is_outside_project_path(&p) => None,
474        Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(&p))),
475        None => Some(format!("{binary} grep {pattern}")),
476    }
477}
478
479/// Rewrites simple `ls [path]` (and PowerShell `Get-ChildItem`/`gci`, #561) to
480/// `lean-ctx ls [path]`.
481fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
482    let parts = shell_tokenize(cmd);
483    match parts.first().map(String::as_str) {
484        Some("ls") => match parts.len() {
485            1 => Some(format!("{binary} ls")),
486            2 if !parts[1].starts_with('-') => {
487                Some(format!("{binary} ls {}", shell_quote(&parts[1])))
488            }
489            _ => None,
490        },
491        Some("Get-ChildItem" | "gci") => rewrite_get_childitem(&parts, binary),
492        _ => None,
493    }
494}
495
496/// Maps `Get-ChildItem`/`gci` to `lean-ctx ls`, honoring `-Path`/`-LiteralPath` and the
497/// positional path. Other flags (e.g. `-Recurse`, `-Filter`) or extra operands pass
498/// through.
499fn rewrite_get_childitem(parts: &[String], binary: &str) -> Option<String> {
500    let mut path: Option<String> = None;
501    let mut i = 1;
502    while i < parts.len() {
503        if let Some(flag) = parts[i].strip_prefix('-') {
504            let value = parts.get(i + 1);
505            match flag.to_ascii_lowercase().as_str() {
506                "path" | "literalpath" => path = Some(value?.clone()),
507                _ => return None,
508            }
509            i += 2;
510        } else if path.is_none() {
511            path = Some(parts[i].clone());
512            i += 1;
513        } else {
514            return None;
515        }
516    }
517    match path {
518        Some(p) => Some(format!("{binary} ls {}", shell_quote(&p))),
519        None => Some(format!("{binary} ls")),
520    }
521}
522
523/// Tokenize a shell command respecting single/double quotes and backslash escapes.
524pub fn shell_tokenize(input: &str) -> Vec<String> {
525    let mut tokens = Vec::new();
526    let mut current = String::new();
527    let mut chars = input.chars().peekable();
528    let mut in_single = false;
529    let mut in_double = false;
530
531    while let Some(c) = chars.next() {
532        match c {
533            '\'' if !in_double => in_single = !in_single,
534            '"' if !in_single => in_double = !in_double,
535            '\\' if !in_single => {
536                if let Some(next) = chars.next() {
537                    current.push(next);
538                }
539            }
540            c if c.is_whitespace() && !in_single && !in_double => {
541                if !current.is_empty() {
542                    tokens.push(std::mem::take(&mut current));
543                }
544            }
545            _ => current.push(c),
546        }
547    }
548    if !current.is_empty() {
549        tokens.push(current);
550    }
551    tokens
552}
553
554/// Quote a path/arg for shell if it contains spaces or special chars.
555pub fn shell_quote(s: &str) -> String {
556    if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
557        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
558    } else {
559        s.to_string()
560    }
561}
562
563fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
564    let mut n: Option<usize> = None;
565    let mut path: Option<&str> = None;
566
567    let mut i = 0;
568    while i < args.len() {
569        if args[i] == "-n" && i + 1 < args.len() {
570            n = args[i + 1].parse().ok();
571            i += 2;
572        } else if let Some(num) = args[i].strip_prefix("-n") {
573            n = num.parse().ok();
574            i += 1;
575        } else if args[i].starts_with('-') && args[i].len() > 1 {
576            if let Ok(num) = args[i][1..].parse::<usize>() {
577                n = Some(num);
578            }
579            i += 1;
580        } else {
581            path = Some(args[i]);
582            i += 1;
583        }
584    }
585
586    (n, path)
587}
588
589fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
590    compound_lexer::rewrite_compound(cmd, |segment| {
591        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
592            return None;
593        }
594        if is_rewritable(segment) {
595            Some(wrap_single_command(segment, binary))
596        } else {
597            None
598        }
599    })
600}
601
602/// The lean-ctx redirect a host tool name maps to, if any.
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
604enum RedirectKind {
605    Read,
606    Grep,
607    Glob,
608    None,
609}
610
611/// Classify a host tool name into the lean-ctx redirect it should take.
612///
613/// Covers the documented read/search/glob tool names across hosts. Copilot CLI
614/// fires the redirect hook for *every* tool call and dispatches purely on the tool
615/// name, so its aliases must be listed here: `view` (its read tool) and `rg` (its
616/// search alias) were previously unmatched and passed through uncompressed (#562).
617fn classify_redirect(tool_name: &str) -> RedirectKind {
618    match tool_name {
619        "Read" | "read" | "read_file" | "view" => RedirectKind::Read,
620        "Grep" | "grep" | "search" | "ripgrep" | "rg" => RedirectKind::Grep,
621        "Glob" | "glob" => RedirectKind::Glob,
622        _ => RedirectKind::None,
623    }
624}
625
626pub fn handle_redirect() {
627    let allow = build_dual_allow_output();
628    if is_disabled() {
629        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
630        print!("{allow}");
631        return;
632    }
633
634    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
635        print!("{allow}");
636        return;
637    };
638
639    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
640        tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
641        print!("{allow}");
642        return;
643    };
644
645    // Normalise host payload shapes (snake_case vs Copilot CLI camelCase, #551).
646    let tool_name = payload::resolve_tool_name(&v).unwrap_or_default();
647    let tool_args = payload::resolve_tool_args(&v);
648
649    match classify_redirect(&tool_name) {
650        RedirectKind::Read => redirect_read(tool_args.as_ref()),
651        RedirectKind::Grep => redirect_grep(tool_args.as_ref()),
652        RedirectKind::Glob => redirect_glob(tool_args.as_ref()),
653        RedirectKind::None => print!("{allow}"),
654    }
655}
656
657/// Redirect Read through lean-ctx for compression + caching.
658/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
659/// prevents daemon auto-start. The subprocess uses the fast local-only path.
660fn redirect_read(tool_input: Option<&serde_json::Value>) {
661    let path = tool_input
662        .and_then(|ti| ti.get("path"))
663        .and_then(|p| p.as_str())
664        .unwrap_or("");
665
666    if path.is_empty() {
667        debug_log::log_hook_decision(
668            "redirect",
669            "Read",
670            Route::Native,
671            "<none>",
672            "no path in tool input",
673        );
674        print!("{}", build_dual_allow_output());
675        return;
676    }
677    if should_passthrough(path) {
678        debug_log::log_hook_decision(
679            "redirect",
680            "Read",
681            Route::Native,
682            path,
683            "passthrough path (sensitive/binary/excluded)",
684        );
685        print!("{}", build_dual_allow_output());
686        return;
687    }
688
689    let shadow = is_shadow_mode_active();
690    if is_harden_active() || shadow {
691        tracing::info!(
692            "[hook redirect] {} active, redirecting Read through lean-ctx",
693            if shadow { "shadow mode" } else { "harden mode" }
694        );
695    }
696
697    let binary = resolve_binary();
698    let temp_path = redirect_temp_path(path);
699
700    if let Some(mut output) =
701        run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT)
702    {
703        if shadow {
704            let header = format!(
705                "[shadow-mode: Read intercepted → ctx_read(\"{path}\", \"full\"). Use ctx_read directly for better performance.]\n\n"
706            );
707            let mut prefixed = header.into_bytes();
708            prefixed.append(&mut output);
709            output = prefixed;
710        }
711        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
712            let temp_str = temp_path.to_str().unwrap_or("");
713            debug_log::log_hook_decision(
714                "redirect",
715                "Read",
716                Route::LeanCtx,
717                path,
718                "redirected to ctx_read",
719            );
720            print!("{}", build_redirect_output(tool_input, "path", temp_str));
721            log_shadow_intercept("Read", path);
722            return;
723        }
724    }
725
726    debug_log::log_hook_decision(
727        "redirect",
728        "Read",
729        Route::Native,
730        path,
731        "lean-ctx read produced no output",
732    );
733    print!("{}", build_dual_allow_output());
734}
735
736/// Redirect Grep through lean-ctx for compressed results.
737/// The Grep redirect rewrites `path` to a temp file the host re-greps, which is
738/// only faithful for `output_mode=content` (see [`redirect_grep`]). For
739/// `files_with_matches` the host would report the temp file itself as the match,
740/// and for `count` it would count lines in the temp file — both wrong. The hook
741/// is host-agnostic (Cursor defaults to `content`, Claude Code to
742/// `files_with_matches`), so an absent mode cannot be assumed safe: only an
743/// explicit `content` mode is redirectable. (GH #398 hook follow-up)
744fn grep_content_mode(tool_input: Option<&serde_json::Value>) -> bool {
745    tool_input
746        .and_then(|ti| ti.get("output_mode"))
747        .and_then(|m| m.as_str())
748        == Some("content")
749}
750
751fn redirect_grep(tool_input: Option<&serde_json::Value>) {
752    let pattern = tool_input
753        .and_then(|ti| ti.get("pattern"))
754        .and_then(|p| p.as_str())
755        .unwrap_or("");
756    let search_path = tool_input
757        .and_then(|ti| ti.get("path"))
758        .and_then(|p| p.as_str())
759        .unwrap_or(".");
760
761    if pattern.is_empty() {
762        debug_log::log_hook_decision(
763            "redirect",
764            "Grep",
765            Route::Native,
766            "<none>",
767            "no pattern in tool input",
768        );
769        print!("{}", build_dual_allow_output());
770        return;
771    }
772
773    if !grep_content_mode(tool_input) {
774        debug_log::log_hook_decision(
775            "redirect",
776            "Grep",
777            Route::Native,
778            &format!("{pattern} in {search_path}"),
779            "non-content output_mode — native passthrough (path-swap only valid for content)",
780        );
781        if is_shadow_mode_active() {
782            log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
783        }
784        print!("{}", build_dual_allow_output());
785        return;
786    }
787
788    let shadow = is_shadow_mode_active();
789    if is_harden_active() || shadow {
790        tracing::info!(
791            "[hook redirect] {} active, redirecting Grep through lean-ctx",
792            if shadow { "shadow mode" } else { "harden mode" }
793        );
794    }
795
796    let binary = resolve_binary();
797    let key = format!("grep:{pattern}:{search_path}");
798    let temp_path = redirect_temp_path(&key);
799
800    if let Some(mut output) = run_with_timeout(
801        &binary,
802        &["grep", pattern, search_path],
803        REDIRECT_SUBPROCESS_TIMEOUT,
804    ) {
805        if shadow {
806            let header = format!(
807                "[shadow-mode: Grep intercepted → ctx_search(\"{pattern}\", \"{search_path}\"). Use ctx_search directly for better performance.]\n\n"
808            );
809            let mut prefixed = header.into_bytes();
810            prefixed.append(&mut output);
811            output = prefixed;
812        }
813        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
814            let temp_str = temp_path.to_str().unwrap_or("");
815            debug_log::log_hook_decision(
816                "redirect",
817                "Grep",
818                Route::LeanCtx,
819                &format!("{pattern} in {search_path}"),
820                "redirected to ctx_search",
821            );
822            print!("{}", build_redirect_output(tool_input, "path", temp_str));
823            log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
824            return;
825        }
826    }
827
828    debug_log::log_hook_decision(
829        "redirect",
830        "Grep",
831        Route::Native,
832        &format!("{pattern} in {search_path}"),
833        "lean-ctx grep produced no output",
834    );
835    print!("{}", build_dual_allow_output());
836}
837
838/// Redirect Glob through lean-ctx in shadow/harden mode (#556).
839///
840/// Glob differs from Read/Grep: its result is a list of paths matched against
841/// the filesystem, not file content, so `build_redirect_output` (which swaps a
842/// field to a temp file the host then *reads*) cannot carry it. We therefore
843/// only act when shadow or harden mode is active — warm lean-ctx's own glob
844/// path (parity with `ctx_glob`) and record the intercept in shadow.log — then
845/// allow the native call through unchanged. Outside those modes there is nothing
846/// to gain, so we pass through immediately without spawning a subprocess.
847fn redirect_glob(tool_input: Option<&serde_json::Value>) {
848    let allow = build_dual_allow_output();
849    let shadow = is_shadow_mode_active();
850    if !shadow && !is_harden_active() {
851        print!("{allow}");
852        return;
853    }
854
855    let pattern = tool_input
856        .and_then(|ti| ti.get("pattern"))
857        .and_then(|p| p.as_str())
858        .unwrap_or("");
859    if pattern.is_empty() {
860        debug_log::log_hook_decision(
861            "redirect",
862            "Glob",
863            Route::Native,
864            "<none>",
865            "no pattern in tool input",
866        );
867        print!("{allow}");
868        return;
869    }
870    let search_path = tool_input
871        .and_then(|ti| ti.get("path"))
872        .and_then(|p| p.as_str())
873        .unwrap_or(".");
874
875    tracing::info!(
876        "[hook redirect] {} active, warming ctx_glob for {pattern}",
877        if shadow { "shadow mode" } else { "harden mode" }
878    );
879
880    // Warm lean-ctx's glob path (populates caches, parity with the ctx_glob the
881    // shadow header nudges toward); the native result is kept untouched.
882    let binary = resolve_binary();
883    let _ = run_with_timeout(
884        &binary,
885        &["glob", pattern, search_path],
886        REDIRECT_SUBPROCESS_TIMEOUT,
887    );
888
889    debug_log::log_hook_decision(
890        "redirect",
891        "Glob",
892        Route::Native,
893        &format!("{pattern} in {search_path}"),
894        "shadow/harden warm — native passthrough",
895    );
896    log_shadow_intercept("Glob", &format!("{pattern} in {search_path}"));
897    print!("{allow}");
898}
899
900const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
901
902/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
903/// Kills the child if it exceeds the timeout to prevent orphan processes.
904fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
905    let mut child = std::process::Command::new(binary)
906        .args(args)
907        .stdout(std::process::Stdio::piped())
908        .stderr(std::process::Stdio::null())
909        .spawn()
910        .ok()?;
911
912    let deadline = std::time::Instant::now() + timeout;
913    loop {
914        match child.try_wait() {
915            Ok(Some(status)) if status.success() => {
916                let mut stdout = Vec::new();
917                if let Some(mut out) = child.stdout.take() {
918                    let _ = out.read_to_end(&mut stdout);
919                }
920                return if stdout.is_empty() {
921                    None
922                } else {
923                    Some(stdout)
924                };
925            }
926            Ok(Some(_)) | Err(_) => return None,
927            Ok(None) => {
928                if std::time::Instant::now() > deadline {
929                    let _ = child.kill();
930                    let _ = child.wait();
931                    return None;
932                }
933                std::thread::sleep(Duration::from_millis(10));
934            }
935        }
936    }
937}
938
939fn redirect_temp_path(key: &str) -> std::path::PathBuf {
940    use std::collections::hash_map::DefaultHasher;
941    use std::hash::{Hash, Hasher};
942
943    let mut hasher = DefaultHasher::new();
944    key.hash(&mut hasher);
945    std::process::id().hash(&mut hasher);
946    let hash = hasher.finish();
947
948    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
949    let _ = std::fs::create_dir_all(&temp_dir);
950    #[cfg(unix)]
951    {
952        use std::os::unix::fs::PermissionsExt;
953        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
954    }
955    temp_dir.join(format!("{hash:016x}.lctx"))
956}
957
958fn build_redirect_output(
959    tool_input: Option<&serde_json::Value>,
960    field: &str,
961    temp_path: &str,
962) -> String {
963    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
964        let mut m = obj.clone();
965        m.insert(
966            field.to_string(),
967            serde_json::Value::String(temp_path.to_string()),
968        );
969        serde_json::Value::Object(m)
970    } else {
971        serde_json::json!({ field: temp_path })
972    };
973
974    serde_json::json!({
975        // Cursor hook output format.
976        "permission": "allow",
977        "updated_input": updated_input.clone(),
978        // GitHub Copilot CLI preToolUse format: top-level `permissionDecision`
979        // + `modifiedArgs` (full substitute args) so the read/grep redirect to
980        // the lean-ctx temp file actually takes effect on Copilot (#551).
981        "permissionDecision": "allow",
982        "modifiedArgs": updated_input.clone(),
983        // Claude Code / CodeBuddy hook output format (other hosts ignore it).
984        "hookSpecificOutput": {
985            "hookEventName": "PreToolUse",
986            "permissionDecision": "allow",
987            "updatedInput": updated_input
988        }
989    })
990    .to_string()
991}
992
993const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
994    ".cursorrules",
995    ".cursor/rules",
996    ".cursor/hooks",
997    "skill.md",
998    "agents.md",
999    ".env",
1000    "hooks.json",
1001    "node_modules",
1002];
1003
1004const PASSTHROUGH_EXTENSIONS: &[&str] = &[
1005    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
1006];
1007
1008fn should_passthrough(path: &str) -> bool {
1009    let p = path.to_lowercase();
1010
1011    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
1012        return true;
1013    }
1014
1015    std::path::Path::new(&p)
1016        .extension()
1017        .and_then(|ext| ext.to_str())
1018        .is_some_and(|ext| {
1019            PASSTHROUGH_EXTENSIONS
1020                .iter()
1021                .any(|e| ext.eq_ignore_ascii_case(e))
1022        })
1023}
1024
1025fn codex_rewrite_output(rewritten: &str) -> String {
1026    serde_json::json!({
1027        "hookSpecificOutput": {
1028            "hookEventName": "PreToolUse",
1029            "permissionDecision": "allow",
1030            "updatedInput": {
1031                "command": rewritten
1032            }
1033        }
1034    })
1035    .to_string()
1036}
1037
1038pub fn handle_codex_pretooluse() {
1039    if is_disabled() {
1040        return;
1041    }
1042    let binary = resolve_binary();
1043    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1044        return;
1045    };
1046
1047    let tool = extract_json_field(&input, "tool_name");
1048    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1049        return;
1050    }
1051
1052    let Some(cmd) = extract_json_field(&input, "command") else {
1053        return;
1054    };
1055
1056    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1057        print!("{}", codex_rewrite_output(&rewritten));
1058    }
1059}
1060
1061/// Emit SessionStart guidance through Codex's documented hidden-context channel.
1062///
1063/// Codex's hook contract (<https://developers.openai.com/codex/hooks>) accepts JSON
1064/// on stdout with `hookSpecificOutput.additionalContext`, which is injected as
1065/// model-visible developer context rather than surfaced to the user as plain text
1066/// (#368). Plain stdout text is also added as developer context today, but only the
1067/// JSON form is the documented additional-context channel; aligning with it
1068/// future-proofs the hook for Codex's TUI-visibility fix (openai/codex#16933) and
1069/// matches how the dedicated rules-injection path already emits context.
1070pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
1071    serde_json::json!({
1072        "hookSpecificOutput": {
1073            "hookEventName": "SessionStart",
1074            "additionalContext": additional_context,
1075        }
1076    })
1077    .to_string()
1078}
1079
1080pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
1081    println!(
1082        "{}",
1083        session_start_additional_context_json(additional_context)
1084    );
1085}
1086
1087pub fn handle_codex_session_start() {
1088    if is_quiet() {
1089        return;
1090    }
1091    // Dedicated rules-injection mode (#343): the `hook observe` SessionStart hook
1092    // injects the full rules summary as additionalContext, so stay silent here to
1093    // avoid double-injecting on Codex (which fires both hooks on SessionStart).
1094    if crate::core::config::Config::load().dedicated_session_context_active() {
1095        return;
1096    }
1097    emit_session_start_additional_context(
1098        "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.",
1099    );
1100}
1101
1102/// Dedicated Copilot PreToolUse handler (dispatched via `hook copilot`).
1103///
1104/// NOTE: the live Copilot CLI integration installed by `init --agent copilot`
1105/// registers `hook rewrite` + `hook redirect` (see `hooks::agents::copilot`),
1106/// so this entry point is currently unused by setup. It is kept correct for any
1107/// host wired to `hook copilot` directly. It parses the same normalised payload
1108/// as the other handlers so Copilot CLI's camelCase `toolName`/`toolArgs`
1109/// (JSON-encoded string) are read correctly (#551).
1110pub fn handle_copilot() {
1111    if is_disabled() {
1112        return;
1113    }
1114    let binary = resolve_binary();
1115    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1116        return;
1117    };
1118
1119    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
1120        return;
1121    };
1122
1123    let Some(tool_name) = payload::resolve_tool_name(&v) else {
1124        return;
1125    };
1126
1127    if !is_shell_tool(&tool_name) {
1128        return;
1129    }
1130
1131    let tool_args = payload::resolve_tool_args(&v);
1132    let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
1133        return;
1134    };
1135
1136    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1137        print!(
1138            "{}",
1139            build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
1140        );
1141    }
1142}
1143
1144/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
1145/// The command is passed as positional arguments, not via stdin JSON.
1146pub fn handle_rewrite_inline() {
1147    if is_disabled() {
1148        return;
1149    }
1150    let binary = resolve_binary();
1151    let args: Vec<String> = std::env::args().collect();
1152    // args: [binary, "hook", "rewrite-inline", ...command parts]
1153    if args.len() < 4 {
1154        return;
1155    }
1156    let cmd = args[3..].join(" ");
1157
1158    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1159        print!("{rewritten}");
1160        return;
1161    }
1162
1163    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1164        print!("{cmd}");
1165        return;
1166    }
1167
1168    print!("{cmd}");
1169}
1170
1171/// Resolve the lean-ctx executable path for hook command emission and
1172/// subprocess spawning. Always the **native** OS path: the MSYS/Git-Bash
1173/// `/c/...` form breaks `CreateProcess` on Windows and cannot be run by
1174/// PowerShell or cmd (#518). Native `C:/...` runs in PowerShell, cmd *and*
1175/// Git Bash, so it is the correct universal form for executed commands.
1176/// (MSYS `/c/...` is only needed for bash *source* lines — see `cli::shell_init`.)
1177fn resolve_binary() -> String {
1178    crate::core::portable_binary::resolve_portable_binary()
1179}
1180
1181fn extract_json_field(input: &str, field: &str) -> Option<String> {
1182    let key = format!("\"{field}\":");
1183    let key_pos = input.find(&key)?;
1184    let after_colon = &input[key_pos + key.len()..];
1185    let trimmed = after_colon.trim_start();
1186    if !trimmed.starts_with('"') {
1187        return None;
1188    }
1189    let rest = &trimmed[1..];
1190    let bytes = rest.as_bytes();
1191    let mut end = 0;
1192    while end < bytes.len() {
1193        if bytes[end] == b'\\' && end + 1 < bytes.len() {
1194            end += 2;
1195            continue;
1196        }
1197        if bytes[end] == b'"' {
1198            break;
1199        }
1200        end += 1;
1201    }
1202    if end >= bytes.len() {
1203        return None;
1204    }
1205    let raw = &rest[..end];
1206    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1207}