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