Skip to main content

lean_ctx/hook_handlers/
mod.rs

1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4use std::sync::mpsc;
5use std::time::Duration;
6
7const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
8mod observe;
9pub use observe::*;
10#[cfg(test)]
11mod tests;
12
13fn is_disabled() -> bool {
14    std::env::var("LEAN_CTX_DISABLED").is_ok()
15}
16
17fn is_harden_active() -> bool {
18    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
19}
20
21fn is_shadow_mode_active() -> bool {
22    if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
23        return true;
24    }
25    crate::core::config::Config::load().shadow_mode
26}
27
28fn log_shadow_intercept(tool: &str, detail: &str) {
29    if !is_shadow_mode_active() {
30        return;
31    }
32    let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
33        return;
34    };
35    let log_path = data_dir.join("shadow.log");
36    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
37    let line = format!("[{ts}] intercepted {tool}: {detail}\n");
38    let _ = std::fs::OpenOptions::new()
39        .create(true)
40        .append(true)
41        .open(log_path)
42        .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
43}
44
45fn is_quiet() -> bool {
46    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
47}
48
49/// Mark this process as a hook child so the daemon-client never auto-starts
50/// the daemon from inside a hook (which would create zombie processes).
51pub fn mark_hook_environment() {
52    // SAFETY: called once at hook-process startup (CLI dispatch), before any
53    // threads that read the environment are spawned.
54    unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
55}
56
57/// Arms a watchdog that force-exits the process after the given duration.
58/// Prevents hook processes from becoming zombies when stdin pipes break or
59/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
60/// (to avoid orphan zombies), a simple exit(1) suffices.
61pub fn arm_watchdog(timeout: Duration) {
62    std::thread::spawn(move || {
63        std::thread::sleep(timeout);
64        eprintln!(
65            "[lean-ctx hook] watchdog timeout after {}s — force exit",
66            timeout.as_secs()
67        );
68        std::process::exit(1);
69    });
70}
71
72/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
73fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
74    let (tx, rx) = mpsc::channel();
75    std::thread::spawn(move || {
76        let mut buf = String::new();
77        let result = std::io::stdin().read_to_string(&mut buf);
78        let _ = tx.send(result.ok().map(|_| buf));
79    });
80    match rx.recv_timeout(timeout) {
81        Ok(Some(s)) if !s.is_empty() => Some(s),
82        _ => None,
83    }
84}
85
86fn build_dual_allow_output() -> String {
87    serde_json::json!({
88        "permission": "allow",
89        "hookSpecificOutput": {
90            "hookEventName": "PreToolUse",
91            "permissionDecision": "allow"
92        }
93    })
94    .to_string()
95}
96
97fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
98    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
99        let mut m = obj.clone();
100        m.insert(
101            "command".to_string(),
102            serde_json::Value::String(rewritten.to_string()),
103        );
104        serde_json::Value::Object(m)
105    } else {
106        serde_json::json!({ "command": rewritten })
107    };
108
109    serde_json::json!({
110        // Cursor hook output format
111        "permission": "allow",
112        "updated_input": updated_input,
113        // Claude Code / CodeBuddy hook output format (extra fields are ignored by other hosts)
114        "hookSpecificOutput": {
115            "hookEventName": "PreToolUse",
116            "permissionDecision": "allow",
117            "updatedInput": {
118                "command": rewritten
119            }
120        }
121    })
122    .to_string()
123}
124
125pub fn handle_rewrite() {
126    let allow = build_dual_allow_output();
127    if is_disabled() {
128        print!("{allow}");
129        return;
130    }
131    let binary = resolve_binary();
132    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
133        print!("{allow}");
134        return;
135    };
136
137    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
138        tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
139        print!("{allow}");
140        return;
141    };
142
143    let tool = v.get("tool_name").and_then(|t| t.as_str());
144    let Some(tool_name) = tool else {
145        print!("{allow}");
146        return;
147    };
148
149    let is_shell_tool = matches!(
150        tool_name,
151        "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
152    );
153    if !is_shell_tool {
154        print!("{allow}");
155        return;
156    }
157
158    let tool_input = v.get("tool_input");
159    let Some(cmd) = tool_input
160        .and_then(|ti| ti.get("command"))
161        .and_then(|c| c.as_str())
162        .or_else(|| v.get("command").and_then(|c| c.as_str()))
163    else {
164        print!("{allow}");
165        return;
166    };
167
168    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
169        print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
170    } else {
171        print!("{allow}");
172    }
173}
174
175fn is_rewritable(cmd: &str) -> bool {
176    rewrite_registry::is_rewritable_command(cmd)
177}
178
179fn wrap_single_command(cmd: &str, binary: &str) -> String {
180    if cfg!(windows) {
181        let escaped = cmd.replace('"', "\\\"");
182        format!("{binary} -c \"{escaped}\"")
183    } else {
184        let shell_escaped = cmd.replace('\'', "'\\''");
185        format!("{binary} -c '{shell_escaped}'")
186    }
187}
188
189fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
190    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
191        return None;
192    }
193
194    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
195    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
196    if cmd.contains("<<") {
197        return None;
198    }
199
200    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
201        return Some(rewritten);
202    }
203
204    if let Some(rewritten) = rewrite_search_command(cmd, binary) {
205        return Some(rewritten);
206    }
207
208    if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
209        return Some(rewritten);
210    }
211
212    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
213        return Some(rewritten);
214    }
215
216    if is_rewritable(cmd) {
217        return Some(wrap_single_command(cmd, binary));
218    }
219
220    None
221}
222
223/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
224/// Only rewrites simple single-file reads within the project scope.
225fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
226    if !rewrite_registry::is_file_read_command(cmd) {
227        return None;
228    }
229
230    // Compound commands (pipes, chains) should not be rewritten as file reads.
231    if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
232        return None;
233    }
234
235    // Shell redirections indicate complex usage — don't rewrite.
236    if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
237        return None;
238    }
239
240    let parts = shell_tokenize(cmd);
241    if parts.len() < 2 {
242        return None;
243    }
244
245    match parts[0].as_str() {
246        "cat" => {
247            let path = parts[1..].join(" ");
248            if is_outside_project_path(&path) {
249                return None;
250            }
251            Some(format!("{binary} read {}", shell_quote(&path)))
252        }
253        "head" => {
254            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
255            let (n, path) = parse_head_tail_args(&refs);
256            let path = path?;
257            if is_outside_project_path(path) {
258                return None;
259            }
260            let qp = shell_quote(path);
261            match n {
262                Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
263                None => Some(format!("{binary} read {qp} -m lines:1-10")),
264            }
265        }
266        "tail" => {
267            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
268            let (n, path) = parse_head_tail_args(&refs);
269            let path = path?;
270            if is_outside_project_path(path) {
271                return None;
272            }
273            let qp = shell_quote(path);
274            let lines = n.unwrap_or(10);
275            Some(format!("{binary} read {qp} -m lines:-{lines}"))
276        }
277        _ => None,
278    }
279}
280
281/// Returns true if the path clearly points outside the current project.
282/// Paths starting with `~`, `$`, or absolute paths that don't resolve
283/// within the working directory should not be intercepted.
284fn is_outside_project_path(path: &str) -> bool {
285    let trimmed = path.trim();
286
287    // Home-relative paths are always outside the project
288    if trimmed.starts_with('~') {
289        return true;
290    }
291
292    // Environment variable expansion — too complex, pass through
293    if trimmed.starts_with('$') {
294        return true;
295    }
296
297    // /proc, /sys, /dev, /tmp, /var — system paths
298    if trimmed.starts_with("/proc/")
299        || trimmed.starts_with("/sys/")
300        || trimmed.starts_with("/dev/")
301        || trimmed.starts_with("/tmp/")
302        || trimmed.starts_with("/var/")
303    {
304        return true;
305    }
306
307    // Absolute paths: only pass through if they clearly point outside.
308    // We can't know the project root here (hooks are stateless), but we can
309    // detect common external patterns.
310    if trimmed.starts_with('/') {
311        // Home directory paths (e.g. /Users/*/Library, /home/*/.config)
312        if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
313            return true;
314        }
315        // lean-ctx's own data directories
316        if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
317            return true;
318        }
319    }
320
321    false
322}
323
324/// Rewrites `rg <pattern> [path]` to `lean-ctx grep <pattern> [path]` for simple forms.
325fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
326    let parts = shell_tokenize(cmd);
327    if parts.first().map(String::as_str) != Some("rg") {
328        return None;
329    }
330    if parts.len() < 2 || parts.len() > 3 {
331        return None;
332    }
333    if parts[1].starts_with('-') {
334        return None;
335    }
336    let pattern = &parts[1];
337    match parts.get(2) {
338        Some(p) if p.starts_with('-') => None,
339        Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
340        None => Some(format!("{binary} grep {pattern}")),
341    }
342}
343
344/// Rewrites simple `ls [path]` to `lean-ctx ls [path]`.
345fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
346    let parts = shell_tokenize(cmd);
347    if parts.first().map(String::as_str) != Some("ls") {
348        return None;
349    }
350    match parts.len() {
351        1 => Some(format!("{binary} ls")),
352        2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", shell_quote(&parts[1]))),
353        _ => None,
354    }
355}
356
357/// Tokenize a shell command respecting single/double quotes and backslash escapes.
358pub fn shell_tokenize(input: &str) -> Vec<String> {
359    let mut tokens = Vec::new();
360    let mut current = String::new();
361    let mut chars = input.chars().peekable();
362    let mut in_single = false;
363    let mut in_double = false;
364
365    while let Some(c) = chars.next() {
366        match c {
367            '\'' if !in_double => in_single = !in_single,
368            '"' if !in_single => in_double = !in_double,
369            '\\' if !in_single => {
370                if let Some(next) = chars.next() {
371                    current.push(next);
372                }
373            }
374            c if c.is_whitespace() && !in_single && !in_double => {
375                if !current.is_empty() {
376                    tokens.push(std::mem::take(&mut current));
377                }
378            }
379            _ => current.push(c),
380        }
381    }
382    if !current.is_empty() {
383        tokens.push(current);
384    }
385    tokens
386}
387
388/// Quote a path/arg for shell if it contains spaces or special chars.
389pub fn shell_quote(s: &str) -> String {
390    if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
391        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
392    } else {
393        s.to_string()
394    }
395}
396
397fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
398    let mut n: Option<usize> = None;
399    let mut path: Option<&str> = None;
400
401    let mut i = 0;
402    while i < args.len() {
403        if args[i] == "-n" && i + 1 < args.len() {
404            n = args[i + 1].parse().ok();
405            i += 2;
406        } else if let Some(num) = args[i].strip_prefix("-n") {
407            n = num.parse().ok();
408            i += 1;
409        } else if args[i].starts_with('-') && args[i].len() > 1 {
410            if let Ok(num) = args[i][1..].parse::<usize>() {
411                n = Some(num);
412            }
413            i += 1;
414        } else {
415            path = Some(args[i]);
416            i += 1;
417        }
418    }
419
420    (n, path)
421}
422
423fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
424    compound_lexer::rewrite_compound(cmd, |segment| {
425        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
426            return None;
427        }
428        if is_rewritable(segment) {
429            Some(wrap_single_command(segment, binary))
430        } else {
431            None
432        }
433    })
434}
435
436fn emit_rewrite(rewritten: &str) {
437    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
438    print!(
439        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
440    );
441}
442
443pub fn handle_redirect() {
444    let allow = build_dual_allow_output();
445    if is_disabled() {
446        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
447        print!("{allow}");
448        return;
449    }
450
451    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
452        print!("{allow}");
453        return;
454    };
455
456    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
457        tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
458        print!("{allow}");
459        return;
460    };
461
462    let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
463    let tool_input = v.get("tool_input");
464
465    match tool_name {
466        "Read" | "read" | "read_file" => redirect_read(tool_input),
467        "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
468        _ => print!("{allow}"),
469    }
470}
471
472/// Redirect Read through lean-ctx for compression + caching.
473/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
474/// prevents daemon auto-start. The subprocess uses the fast local-only path.
475fn redirect_read(tool_input: Option<&serde_json::Value>) {
476    let path = tool_input
477        .and_then(|ti| ti.get("path"))
478        .and_then(|p| p.as_str())
479        .unwrap_or("");
480
481    if path.is_empty() || should_passthrough(path) {
482        print!("{}", build_dual_allow_output());
483        return;
484    }
485
486    let shadow = is_shadow_mode_active();
487    if is_harden_active() || shadow {
488        tracing::info!(
489            "[hook redirect] {} active, redirecting Read through lean-ctx",
490            if shadow { "shadow mode" } else { "harden mode" }
491        );
492    }
493
494    let binary = resolve_binary();
495    let temp_path = redirect_temp_path(path);
496
497    if let Some(mut output) =
498        run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT)
499    {
500        if shadow {
501            let header = format!(
502                "[shadow-mode: Read intercepted → ctx_read(\"{path}\", \"full\"). Use ctx_read directly for better performance.]\n\n"
503            );
504            let mut prefixed = header.into_bytes();
505            prefixed.append(&mut output);
506            output = prefixed;
507        }
508        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
509            let temp_str = temp_path.to_str().unwrap_or("");
510            print!("{}", build_redirect_output(tool_input, "path", temp_str));
511            log_shadow_intercept("Read", path);
512            return;
513        }
514    }
515
516    print!("{}", build_dual_allow_output());
517}
518
519/// Redirect Grep through lean-ctx for compressed results.
520fn redirect_grep(tool_input: Option<&serde_json::Value>) {
521    let pattern = tool_input
522        .and_then(|ti| ti.get("pattern"))
523        .and_then(|p| p.as_str())
524        .unwrap_or("");
525    let search_path = tool_input
526        .and_then(|ti| ti.get("path"))
527        .and_then(|p| p.as_str())
528        .unwrap_or(".");
529
530    if pattern.is_empty() {
531        print!("{}", build_dual_allow_output());
532        return;
533    }
534
535    let shadow = is_shadow_mode_active();
536    if is_harden_active() || shadow {
537        tracing::info!(
538            "[hook redirect] {} active, redirecting Grep through lean-ctx",
539            if shadow { "shadow mode" } else { "harden mode" }
540        );
541    }
542
543    let binary = resolve_binary();
544    let key = format!("grep:{pattern}:{search_path}");
545    let temp_path = redirect_temp_path(&key);
546
547    if let Some(mut output) = run_with_timeout(
548        &binary,
549        &["grep", pattern, search_path],
550        REDIRECT_SUBPROCESS_TIMEOUT,
551    ) {
552        if shadow {
553            let header = format!(
554                "[shadow-mode: Grep intercepted → ctx_search(\"{pattern}\", \"{search_path}\"). Use ctx_search directly for better performance.]\n\n"
555            );
556            let mut prefixed = header.into_bytes();
557            prefixed.append(&mut output);
558            output = prefixed;
559        }
560        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
561            let temp_str = temp_path.to_str().unwrap_or("");
562            print!("{}", build_redirect_output(tool_input, "path", temp_str));
563            log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
564            return;
565        }
566    }
567
568    print!("{}", build_dual_allow_output());
569}
570
571const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
572
573/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
574/// Kills the child if it exceeds the timeout to prevent orphan processes.
575fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
576    let mut child = std::process::Command::new(binary)
577        .args(args)
578        .stdout(std::process::Stdio::piped())
579        .stderr(std::process::Stdio::null())
580        .spawn()
581        .ok()?;
582
583    let deadline = std::time::Instant::now() + timeout;
584    loop {
585        match child.try_wait() {
586            Ok(Some(status)) if status.success() => {
587                let mut stdout = Vec::new();
588                if let Some(mut out) = child.stdout.take() {
589                    let _ = out.read_to_end(&mut stdout);
590                }
591                return if stdout.is_empty() {
592                    None
593                } else {
594                    Some(stdout)
595                };
596            }
597            Ok(Some(_)) | Err(_) => return None,
598            Ok(None) => {
599                if std::time::Instant::now() > deadline {
600                    let _ = child.kill();
601                    let _ = child.wait();
602                    return None;
603                }
604                std::thread::sleep(Duration::from_millis(10));
605            }
606        }
607    }
608}
609
610fn redirect_temp_path(key: &str) -> std::path::PathBuf {
611    use std::collections::hash_map::DefaultHasher;
612    use std::hash::{Hash, Hasher};
613
614    let mut hasher = DefaultHasher::new();
615    key.hash(&mut hasher);
616    std::process::id().hash(&mut hasher);
617    let hash = hasher.finish();
618
619    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
620    let _ = std::fs::create_dir_all(&temp_dir);
621    #[cfg(unix)]
622    {
623        use std::os::unix::fs::PermissionsExt;
624        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
625    }
626    temp_dir.join(format!("{hash:016x}.lctx"))
627}
628
629fn build_redirect_output(
630    tool_input: Option<&serde_json::Value>,
631    field: &str,
632    temp_path: &str,
633) -> String {
634    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
635        let mut m = obj.clone();
636        m.insert(
637            field.to_string(),
638            serde_json::Value::String(temp_path.to_string()),
639        );
640        serde_json::Value::Object(m)
641    } else {
642        serde_json::json!({ field: temp_path })
643    };
644
645    serde_json::json!({
646        "permission": "allow",
647        "updated_input": updated_input,
648        "hookSpecificOutput": {
649            "hookEventName": "PreToolUse",
650            "permissionDecision": "allow",
651            "updatedInput": { field: temp_path }
652        }
653    })
654    .to_string()
655}
656
657const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
658    ".cursorrules",
659    ".cursor/rules",
660    ".cursor/hooks",
661    "skill.md",
662    "agents.md",
663    ".env",
664    "hooks.json",
665    "node_modules",
666];
667
668const PASSTHROUGH_EXTENSIONS: &[&str] = &[
669    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
670];
671
672fn should_passthrough(path: &str) -> bool {
673    let p = path.to_lowercase();
674
675    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
676        return true;
677    }
678
679    std::path::Path::new(&p)
680        .extension()
681        .and_then(|ext| ext.to_str())
682        .is_some_and(|ext| {
683            PASSTHROUGH_EXTENSIONS
684                .iter()
685                .any(|e| ext.eq_ignore_ascii_case(e))
686        })
687}
688
689fn codex_rewrite_output(rewritten: &str) -> String {
690    serde_json::json!({
691        "hookSpecificOutput": {
692            "hookEventName": "PreToolUse",
693            "permissionDecision": "allow",
694            "updatedInput": {
695                "command": rewritten
696            }
697        }
698    })
699    .to_string()
700}
701
702pub fn handle_codex_pretooluse() {
703    if is_disabled() {
704        return;
705    }
706    let binary = resolve_binary();
707    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
708        return;
709    };
710
711    let tool = extract_json_field(&input, "tool_name");
712    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
713        return;
714    }
715
716    let Some(cmd) = extract_json_field(&input, "command") else {
717        return;
718    };
719
720    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
721        print!("{}", codex_rewrite_output(&rewritten));
722    }
723}
724
725/// Emit SessionStart guidance through Codex's documented hidden-context channel.
726///
727/// Codex's hook contract (<https://developers.openai.com/codex/hooks>) accepts JSON
728/// on stdout with `hookSpecificOutput.additionalContext`, which is injected as
729/// model-visible developer context rather than surfaced to the user as plain text
730/// (#368). Plain stdout text is also added as developer context today, but only the
731/// JSON form is the documented additional-context channel; aligning with it
732/// future-proofs the hook for Codex's TUI-visibility fix (openai/codex#16933) and
733/// matches how the dedicated rules-injection path already emits context.
734pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
735    serde_json::json!({
736        "hookSpecificOutput": {
737            "hookEventName": "SessionStart",
738            "additionalContext": additional_context,
739        }
740    })
741    .to_string()
742}
743
744pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
745    println!(
746        "{}",
747        session_start_additional_context_json(additional_context)
748    );
749}
750
751pub fn handle_codex_session_start() {
752    if is_quiet() {
753        return;
754    }
755    // Dedicated rules-injection mode (#343): the `hook observe` SessionStart hook
756    // injects the full rules summary as additionalContext, so stay silent here to
757    // avoid double-injecting on Codex (which fires both hooks on SessionStart).
758    if crate::core::config::Config::load().dedicated_session_context_active() {
759        return;
760    }
761    emit_session_start_additional_context(
762        "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.",
763    );
764}
765
766/// Copilot-specific PreToolUse handler.
767/// VS Code Copilot Chat uses the same hook format as Claude Code.
768/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
769pub fn handle_copilot() {
770    if is_disabled() {
771        return;
772    }
773    let binary = resolve_binary();
774    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
775        return;
776    };
777
778    let tool = extract_json_field(&input, "tool_name");
779    let Some(tool_name) = tool.as_deref() else {
780        return;
781    };
782
783    let is_shell_tool = matches!(
784        tool_name,
785        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
786    );
787    if !is_shell_tool {
788        return;
789    }
790
791    let Some(cmd) = extract_json_field(&input, "command") else {
792        return;
793    };
794
795    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
796        emit_rewrite(&rewritten);
797    }
798}
799
800/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
801/// Used by the OpenCode TS plugin where the command is passed as an argument,
802/// not via stdin JSON. Uses native OS paths (not MSYS) because the calling
803/// shell may be PowerShell or cmd on Windows.
804pub fn handle_rewrite_inline() {
805    if is_disabled() {
806        return;
807    }
808    let binary = resolve_binary_native();
809    let args: Vec<String> = std::env::args().collect();
810    // args: [binary, "hook", "rewrite-inline", ...command parts]
811    if args.len() < 4 {
812        return;
813    }
814    let cmd = args[3..].join(" ");
815
816    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
817        print!("{rewritten}");
818        return;
819    }
820
821    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
822        print!("{cmd}");
823        return;
824    }
825
826    print!("{cmd}");
827}
828
829fn resolve_binary() -> String {
830    let path = crate::core::portable_binary::resolve_portable_binary();
831    crate::hooks::to_bash_compatible_path(&path)
832}
833
834fn resolve_binary_native() -> String {
835    crate::core::portable_binary::resolve_portable_binary()
836}
837
838fn extract_json_field(input: &str, field: &str) -> Option<String> {
839    let key = format!("\"{field}\":");
840    let key_pos = input.find(&key)?;
841    let after_colon = &input[key_pos + key.len()..];
842    let trimmed = after_colon.trim_start();
843    if !trimmed.starts_with('"') {
844        return None;
845    }
846    let rest = &trimmed[1..];
847    let bytes = rest.as_bytes();
848    let mut end = 0;
849    while end < bytes.len() {
850        if bytes[end] == b'\\' && end + 1 < bytes.len() {
851            end += 2;
852            continue;
853        }
854        if bytes[end] == b'"' {
855            break;
856        }
857        end += 1;
858    }
859    if end >= bytes.len() {
860        return None;
861    }
862    let raw = &rest[..end];
863    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
864}