Skip to main content

lean_ctx/hook_handlers/
mod.rs

1use crate::core::debug_log::{self, Route};
2use std::io::Read;
3use std::sync::mpsc;
4use std::time::Duration;
5
6const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
7
8/// Hard wall-clock budget for a command-gating hook (rewrite/redirect) to produce
9/// its decision. Sized above the worst legitimate single read path (stdin 3s +
10/// redirect subprocess 10s) so valid work always completes; a true hang — or a
11/// dead-winner dedup loser that would otherwise wait then redo the work — is
12/// bounded here and FAILS OPEN instead of wedging the host's tool call (#1035).
13const HOOK_GATING_TIMEOUT: Duration = Duration::from_secs(15);
14mod codex;
15mod dedup;
16mod deny;
17mod edit_health;
18// Command-rewrite (#660/#966 LOC gate): file-read rewrites, compound wrapping,
19// and the rewrite_candidate dispatch every rewrite entry point funnels through.
20mod file_rewrite;
21mod observe;
22mod payload;
23// Redirect decision logic (#660/#966 LOC gate) for Read/Grep/Glob.
24mod read_dedup;
25mod redirect;
26// Search/dir-list rewriting and shell tokenization extracted to
27// `search_rewrite` submodule (#660 LOC gate).
28mod search_rewrite;
29mod vibe;
30pub(crate) use codex::emit_session_start_additional_context;
31pub use codex::{handle_codex_pretooluse, handle_codex_session_start};
32pub use vibe::handle_vibe_pre_tool;
33// Test-only re-export: only `hook_handlers::tests` (cfg(test)) reaches these
34// through this path; codex.rs's own production use of them is internal.
35#[cfg(test)]
36pub(crate) use codex::{CODEX_SHELL_RECOVERY_HINT, session_start_additional_context_json};
37pub use deny::handle_deny;
38pub use observe::*;
39pub use read_dedup::handle_read_dedup;
40pub use search_rewrite::{shell_quote, shell_tokenize};
41#[cfg(test)]
42mod tests;
43
44// Test-only re-exports: `hook_handlers::tests` (and its `tests_rewrite_extras`
45// submodule) reference these private implementation functions directly by
46// bare name via `use super::*`; production code calls through the owning
47// module's path instead (e.g. `file_rewrite::rewrite_candidate`).
48#[cfg(test)]
49use codex::{codex_allow_output, codex_deny_output, codex_rewrite_output};
50#[cfg(test)]
51use file_rewrite::{
52    build_rewrite_compound, is_outside_project_path, is_rewritable, parse_head_tail_args,
53    rewrite_candidate, rewrite_file_read_command, rewrite_skip_reason, wrap_single_command,
54};
55#[cfg(test)]
56use redirect::{
57    RedirectKind, build_redirect_output, classify_redirect, grep_content_mode, redirect_read,
58    redirect_read_args, should_passthrough, warm_daemon_cache,
59};
60#[cfg(test)]
61use search_rewrite::{rewrite_dir_list_command, rewrite_search_command};
62
63fn is_disabled() -> bool {
64    std::env::var("LEAN_CTX_DISABLED").is_ok()
65}
66
67fn is_harden_active() -> bool {
68    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
69}
70
71fn is_shadow_mode_active() -> bool {
72    if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
73        return true;
74    }
75    crate::core::config::Config::load().shadow_mode
76}
77
78fn log_shadow_intercept(tool: &str, detail: &str) {
79    if !is_shadow_mode_active() {
80        return;
81    }
82    let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
83        return;
84    };
85    let log_path = data_dir.join("shadow.log");
86    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
87    let line = format!("[{ts}] intercepted {tool}: {detail}\n");
88    let _ = std::fs::OpenOptions::new()
89        .create(true)
90        .append(true)
91        .open(log_path)
92        .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
93}
94
95fn is_quiet() -> bool {
96    crate::core::runtime_flags::quiet_enabled()
97}
98
99/// Mark this process as a hook child so the daemon-client never auto-starts
100/// the daemon from inside a hook (which would create zombie processes).
101pub fn mark_hook_environment() {
102    crate::core::runtime_flags::mark_hook_child();
103}
104
105/// Arms a watchdog that force-exits the process after the given duration.
106/// Prevents hook processes from becoming zombies when stdin pipes break or
107/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
108/// (to avoid orphan zombies), a simple exit(1) suffices.
109pub fn arm_watchdog(timeout: Duration) {
110    std::thread::spawn(move || {
111        std::thread::sleep(timeout);
112        eprintln!(
113            "[lean-ctx hook] watchdog timeout after {}s — force exit",
114            timeout.as_secs()
115        );
116        std::process::exit(1);
117    });
118}
119
120/// Run a command-gating hook's decision logic under a hard wall-clock timeout and
121/// print the result exactly once.
122///
123/// On timeout the hook FAILS OPEN — it prints the allow/pass-through decision so a
124/// slow or hung hook (a stalled subprocess, a wedged dedup wait, a saturated host)
125/// can never block the host's tool call: the command simply runs unmodified
126/// (#1035). The worker thread is abandoned on timeout (it only sends to a channel,
127/// never prints, and dies with the process), so there is no double-output race —
128/// `emit_gating_decision` is the single writer to stdout.
129fn emit_gating_decision<F>(timeout: Duration, work: F)
130where
131    F: FnOnce() -> String + Send + 'static,
132{
133    let out = decide_with_timeout(timeout, build_dual_allow_output(), work);
134    print!("{out}");
135}
136
137/// Run `work` under a hard wall-clock timeout, returning `fallback` if it does not
138/// finish in time. Split from [`emit_gating_decision`]'s printing so the fail-open
139/// behavior is unit-testable. The worker only sends to a channel (it never prints)
140/// and is abandoned on timeout, so it can never double-write the host's stdout
141/// (#1035).
142fn decide_with_timeout<F>(timeout: Duration, fallback: String, work: F) -> String
143where
144    F: FnOnce() -> String + Send + 'static,
145{
146    let (tx, rx) = mpsc::channel();
147    std::thread::spawn(move || {
148        let _ = tx.send(work());
149    });
150    rx.recv_timeout(timeout).unwrap_or(fallback)
151}
152
153/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
154fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
155    let (tx, rx) = mpsc::channel();
156    std::thread::spawn(move || {
157        let mut buf = String::new();
158        let result = std::io::stdin().read_to_string(&mut buf);
159        let _ = tx.send(result.ok().map(|_| buf));
160    });
161    match rx.recv_timeout(timeout) {
162        Ok(Some(s)) if !s.is_empty() => Some(s),
163        _ => None,
164    }
165}
166
167fn build_dual_allow_output() -> String {
168    serde_json::json!({
169        "permission": "allow",
170        "hookSpecificOutput": {
171            "hookEventName": "PreToolUse",
172            "permissionDecision": "allow"
173        }
174    })
175    .to_string()
176}
177
178fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
179    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
180        let mut m = obj.clone();
181        m.insert(
182            "command".to_string(),
183            serde_json::Value::String(rewritten.to_string()),
184        );
185        serde_json::Value::Object(m)
186    } else {
187        serde_json::json!({ "command": rewritten })
188    };
189
190    serde_json::json!({
191        // Cursor hook output format.
192        "permission": "allow",
193        "updated_input": updated_input.clone(),
194        // GitHub Copilot CLI preToolUse format: top-level `permissionDecision`
195        // + `modifiedArgs` (a full substitute-args object). Copilot ignores
196        // `hookSpecificOutput`, so without these fields it runs the command
197        // unmodified even after the camelCase payload parses correctly (#551).
198        "permissionDecision": "allow",
199        "modifiedArgs": updated_input.clone(),
200        // Claude Code / CodeBuddy hook output format (other hosts ignore it).
201        "hookSpecificOutput": {
202            "hookEventName": "PreToolUse",
203            "permissionDecision": "allow",
204            "updatedInput": updated_input
205        }
206    })
207    .to_string()
208}
209
210/// True when a host tool name denotes a shell/terminal command tool.
211///
212/// Copilot CLI exposes `powershell` as a first-class shell tool on Windows
213/// (paired with `bash` per the CLI tool reference); without it Windows shell
214/// calls bypass rewrite (#556). Shared by `handle_rewrite` and `handle_copilot`.
215fn is_shell_tool(tool_name: &str) -> bool {
216    matches!(
217        tool_name,
218        "Bash"
219            | "bash"
220            | "Shell"
221            | "shell"
222            | "sh"
223            | "runInTerminal"
224            | "run_in_terminal"
225            | "run_terminal"
226            | "runterminal"
227            | "run_command"
228            | "run_shell_command"
229            | "run_terminal_command"
230            | "execute_command"
231            | "exec_command"
232            | "command_exec"
233            | "run"
234            | "exec"
235            | "execute"
236            | "command"
237            | "cmd"
238            | "terminal"
239            | "PowerShell"
240            | "powershell"
241            | "pwsh"
242    )
243}
244
245pub fn handle_rewrite() {
246    emit_gating_decision(HOOK_GATING_TIMEOUT, file_rewrite::compute_rewrite);
247}
248
249pub fn handle_redirect() {
250    emit_gating_decision(HOOK_GATING_TIMEOUT, redirect::compute_redirect);
251}
252
253/// Dedicated Copilot PreToolUse handler (dispatched via `hook copilot`).
254///
255/// NOTE: the live Copilot CLI integration installed by `init --agent copilot`
256/// registers `hook rewrite` + `hook redirect` (see `hooks::agents::copilot`),
257/// so this entry point is currently unused by setup. It is kept correct for any
258/// host wired to `hook copilot` directly. It parses the same normalised payload
259/// as the other handlers so Copilot CLI's camelCase `toolName`/`toolArgs`
260/// (JSON-encoded string) are read correctly (#551).
261pub fn handle_copilot() {
262    if is_disabled() {
263        return;
264    }
265    let binary = resolve_binary();
266    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
267        return;
268    };
269
270    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
271        return;
272    };
273
274    let Some(tool_name) = payload::resolve_tool_name(&v) else {
275        return;
276    };
277
278    if !is_shell_tool(&tool_name) {
279        return;
280    }
281
282    let tool_args = payload::resolve_tool_args(&v);
283    let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
284        return;
285    };
286
287    if let Some(rewritten) = file_rewrite::rewrite_candidate(&cmd, &binary) {
288        print!(
289            "{}",
290            build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
291        );
292    }
293}
294
295/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
296/// The command is passed as positional arguments, not via stdin JSON.
297pub fn handle_rewrite_inline() {
298    if is_disabled() {
299        return;
300    }
301    let binary = resolve_binary();
302    let args: Vec<String> = std::env::args().collect();
303    // args: [binary, "hook", "rewrite-inline", ...command parts]
304    if args.len() < 4 {
305        return;
306    }
307    let cmd = args[3..].join(" ");
308
309    if let Some(rewritten) = file_rewrite::rewrite_candidate(&cmd, &binary) {
310        print!("{rewritten}");
311        return;
312    }
313
314    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
315        print!("{cmd}");
316        return;
317    }
318
319    print!("{cmd}");
320}
321
322/// Resolve the lean-ctx executable path for hook command emission and
323/// subprocess spawning. Always the **native** OS path: the MSYS/Git-Bash
324/// `/c/...` form breaks `CreateProcess` on Windows and cannot be run by
325/// PowerShell or cmd (#518). Native `C:/...` runs in PowerShell, cmd *and*
326/// Git Bash, so it is the correct universal form for executed commands.
327/// (MSYS `/c/...` is only needed for bash *source* lines — see `cli::shell_init`.)
328fn resolve_binary() -> String {
329    crate::core::portable_binary::resolve_portable_binary()
330}
331
332#[cfg(test)]
333fn extract_json_field(input: &str, field: &str) -> Option<String> {
334    let key = format!("\"{field}\":");
335    let key_pos = input.find(&key)?;
336    let after_colon = &input[key_pos + key.len()..];
337    let trimmed = after_colon.trim_start();
338    if !trimmed.starts_with('"') {
339        return None;
340    }
341    let rest = &trimmed[1..];
342    let bytes = rest.as_bytes();
343    let mut end = 0;
344    while end < bytes.len() {
345        if bytes[end] == b'\\' && end + 1 < bytes.len() {
346            end += 2;
347            continue;
348        }
349        if bytes[end] == b'"' {
350            break;
351        }
352        end += 1;
353    }
354    if end >= bytes.len() {
355        return None;
356    }
357    let raw = &rest[..end];
358    Some(unescape_json_string(raw))
359}
360
361/// Single-pass JSON string unescaping (#787).
362///
363/// Handles \\, \", \n, \t, \r, \/ — the standard JSON escape sequences
364/// that agents actually emit in hook payloads. \uXXXX is passed through
365/// unchanged (extremely rare in shell commands, not worth the complexity).
366#[cfg(test)]
367fn unescape_json_string(s: &str) -> String {
368    let mut out = String::with_capacity(s.len());
369    let mut chars = s.chars();
370    while let Some(c) = chars.next() {
371        if c == '\\' {
372            match chars.next() {
373                Some('n') => out.push('\n'),
374                Some('t') => out.push('\t'),
375                Some('r') => out.push('\r'),
376                Some('"') => out.push('"'),
377                Some('/') => out.push('/'),
378                Some('\\') | None => out.push('\\'),
379                Some(other) => {
380                    out.push('\\');
381                    out.push(other);
382                }
383            }
384        } else {
385            out.push(c);
386        }
387    }
388    out
389}