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