lean-ctx 3.9.19

Context Runtime for AI Agents with CCP. 79 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
use crate::core::debug_log::{self, Route};
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;

const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);

/// Hard wall-clock budget for a command-gating hook (rewrite/redirect) to produce
/// its decision. Sized above the worst legitimate single read path (stdin 3s +
/// redirect subprocess 10s) so valid work always completes; a true hang — or a
/// dead-winner dedup loser that would otherwise wait then redo the work — is
/// bounded here and FAILS OPEN instead of wedging the host's tool call (#1035).
const HOOK_GATING_TIMEOUT: Duration = Duration::from_secs(15);
mod codex;
mod dedup;
mod deny;
mod edit_health;
// Command-rewrite (#660/#966 LOC gate): file-read rewrites, compound wrapping,
// and the rewrite_candidate dispatch every rewrite entry point funnels through.
mod file_rewrite;
mod observe;
mod payload;
// Redirect decision logic (#660/#966 LOC gate) for Read/Grep/Glob.
mod read_dedup;
mod redirect;
// Search/dir-list rewriting and shell tokenization extracted to
// `search_rewrite` submodule (#660 LOC gate).
mod search_rewrite;
pub mod solution_capture;
mod vibe;
pub(crate) use codex::emit_session_start_additional_context;
pub use codex::{handle_codex_pretooluse, handle_codex_session_start};
pub use solution_capture::handle_post_commit;
pub use vibe::handle_vibe_pre_tool;
// Test-only re-export: only `hook_handlers::tests` (cfg(test)) reaches these
// through this path; codex.rs's own production use of them is internal.
#[cfg(test)]
#[cfg(test)]
pub(crate) use codex::{CODEX_SHELL_RECOVERY_HINT, session_start_additional_context_json};
pub use deny::handle_deny;
pub use observe::*;
pub use read_dedup::handle_read_dedup;
pub use search_rewrite::{shell_quote, shell_tokenize};
#[cfg(test)]
mod tests;

// Test-only re-exports: `hook_handlers::tests` (and its `tests_rewrite_extras`
// submodule) reference these private implementation functions directly by
// bare name via `use super::*`; production code calls through the owning
// module's path instead (e.g. `file_rewrite::rewrite_candidate`).
#[cfg(test)]
use codex::{codex_allow_output, codex_deny_output, codex_rewrite_output};
#[cfg(test)]
use file_rewrite::{
    build_rewrite_compound, is_outside_project_path, is_rewritable, parse_head_tail_args,
    rewrite_candidate, rewrite_file_read_command, rewrite_skip_reason, wrap_single_command,
};
#[cfg(test)]
use redirect::{
    RedirectKind, build_redirect_output, classify_redirect, grep_content_mode, redirect_read,
    redirect_read_args, should_passthrough, warm_daemon_cache,
};
#[cfg(test)]
use search_rewrite::{rewrite_dir_list_command, rewrite_search_command};

fn is_disabled() -> bool {
    std::env::var("LEAN_CTX_DISABLED").is_ok()
}

fn is_harden_active() -> bool {
    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
}

fn is_shadow_mode_active() -> bool {
    if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
        return true;
    }
    crate::core::config::Config::load().shadow_mode
}

/// True when `tool_surface = "shadow"` is explicitly configured.
/// In this mode, native Read/Shell/Grep/Glob pass through unmodified;
/// hooks only observe for metrics, never redirect or compress.
fn is_shadow_surface_active() -> bool {
    if let Ok(v) = std::env::var("LEAN_CTX_TOOL_SURFACE") {
        return v.eq_ignore_ascii_case("shadow");
    }
    let cfg = crate::core::config::Config::load();
    matches!(cfg.tool_surface.as_deref(), Some("shadow"))
}

fn log_shadow_intercept(tool: &str, detail: &str) {
    if !is_shadow_mode_active() {
        return;
    }
    let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
        return;
    };
    let log_path = data_dir.join("shadow.log");
    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
    let line = format!("[{ts}] intercepted {tool}: {detail}\n");
    let _ = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_path)
        .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
}

fn is_quiet() -> bool {
    crate::core::runtime_flags::quiet_enabled()
}

/// Mark this process as a hook child so the daemon-client never auto-starts
/// the daemon from inside a hook (which would create zombie processes).
pub fn mark_hook_environment() {
    crate::core::runtime_flags::mark_hook_child();
}

/// Arms a watchdog that force-exits the process after the given duration.
/// Prevents hook processes from becoming zombies when stdin pipes break or
/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
/// (to avoid orphan zombies), a simple exit(1) suffices.
pub fn arm_watchdog(timeout: Duration) {
    std::thread::spawn(move || {
        std::thread::sleep(timeout);
        eprintln!(
            "[lean-ctx hook] watchdog timeout after {}s — force exit",
            timeout.as_secs()
        );
        std::process::exit(1);
    });
}

/// Run a command-gating hook's decision logic under a hard wall-clock timeout and
/// print the result exactly once.
///
/// On timeout the hook FAILS OPEN — it prints the allow/pass-through decision so a
/// slow or hung hook (a stalled subprocess, a wedged dedup wait, a saturated host)
/// can never block the host's tool call: the command simply runs unmodified
/// (#1035). The worker thread is abandoned on timeout (it only sends to a channel,
/// never prints, and dies with the process), so there is no double-output race —
/// `emit_gating_decision` is the single writer to stdout.
fn emit_gating_decision<F>(timeout: Duration, work: F)
where
    F: FnOnce() -> String + Send + 'static,
{
    let out = decide_with_timeout(timeout, build_dual_allow_output(), work);
    print!("{out}");
}

/// Run `work` under a hard wall-clock timeout, returning `fallback` if it does not
/// finish in time. Split from [`emit_gating_decision`]'s printing so the fail-open
/// behavior is unit-testable. The worker only sends to a channel (it never prints)
/// and is abandoned on timeout, so it can never double-write the host's stdout
/// (#1035).
fn decide_with_timeout<F>(timeout: Duration, fallback: String, work: F) -> String
where
    F: FnOnce() -> String + Send + 'static,
{
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(work());
    });
    rx.recv_timeout(timeout).unwrap_or(fallback)
}

/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let mut buf = String::new();
        let result = std::io::stdin().read_to_string(&mut buf);
        let _ = tx.send(result.ok().map(|_| buf));
    });
    match rx.recv_timeout(timeout) {
        Ok(Some(s)) if !s.is_empty() => Some(s),
        _ => None,
    }
}

fn build_dual_allow_output() -> String {
    serde_json::json!({
        "permission": "allow",
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow"
        }
    })
    .to_string()
}

fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
        let mut m = obj.clone();
        m.insert(
            "command".to_string(),
            serde_json::Value::String(rewritten.to_string()),
        );
        serde_json::Value::Object(m)
    } else {
        serde_json::json!({ "command": rewritten })
    };

    serde_json::json!({
        // Cursor hook output format.
        "permission": "allow",
        "updated_input": updated_input.clone(),
        // GitHub Copilot CLI preToolUse format: top-level `permissionDecision`
        // + `modifiedArgs` (a full substitute-args object). Copilot ignores
        // `hookSpecificOutput`, so without these fields it runs the command
        // unmodified even after the camelCase payload parses correctly (#551).
        "permissionDecision": "allow",
        "modifiedArgs": updated_input.clone(),
        // Claude Code / CodeBuddy hook output format (other hosts ignore it).
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "updatedInput": updated_input
        }
    })
    .to_string()
}

/// True when a host tool name denotes a shell/terminal command tool.
///
/// Copilot CLI exposes `powershell` as a first-class shell tool on Windows
/// (paired with `bash` per the CLI tool reference); without it Windows shell
/// calls bypass rewrite (#556). Shared by `handle_rewrite` and `handle_copilot`.
fn is_shell_tool(tool_name: &str) -> bool {
    matches!(
        tool_name,
        "Bash"
            | "bash"
            | "Shell"
            | "shell"
            | "sh"
            | "runInTerminal"
            | "run_in_terminal"
            | "run_terminal"
            | "runterminal"
            | "run_command"
            | "run_shell_command"
            | "run_terminal_command"
            | "execute_command"
            | "exec_command"
            | "command_exec"
            | "run"
            | "exec"
            | "execute"
            | "command"
            | "cmd"
            | "terminal"
            | "PowerShell"
            | "powershell"
            | "pwsh"
    )
}

pub fn handle_rewrite() {
    emit_gating_decision(HOOK_GATING_TIMEOUT, file_rewrite::compute_rewrite);
}

pub fn handle_redirect() {
    emit_gating_decision(HOOK_GATING_TIMEOUT, redirect::compute_redirect);
}

/// Dedicated Copilot PreToolUse handler (dispatched via `hook copilot`).
///
/// NOTE: the live Copilot CLI integration installed by `init --agent copilot`
/// registers `hook rewrite` + `hook redirect` (see `hooks::agents::copilot`),
/// so this entry point is currently unused by setup. It is kept correct for any
/// host wired to `hook copilot` directly. It parses the same normalised payload
/// as the other handlers so Copilot CLI's camelCase `toolName`/`toolArgs`
/// (JSON-encoded string) are read correctly (#551).
pub fn handle_copilot() {
    if is_disabled() {
        return;
    }
    let binary = resolve_binary();
    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
        return;
    };

    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
        return;
    };

    let Some(tool_name) = payload::resolve_tool_name(&v) else {
        return;
    };

    if !is_shell_tool(&tool_name) {
        return;
    }

    let tool_args = payload::resolve_tool_args(&v);
    let Some(cmd) = payload::resolve_command(&v, tool_args.as_ref()) else {
        return;
    };

    if let Some(rewritten) = file_rewrite::rewrite_candidate(&cmd, &binary) {
        print!(
            "{}",
            build_dual_rewrite_output(tool_args.as_ref(), &rewritten)
        );
    }
}

/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
/// The command is passed as positional arguments, not via stdin JSON.
pub fn handle_rewrite_inline() {
    if is_disabled() {
        return;
    }
    let binary = resolve_binary();
    let args: Vec<String> = std::env::args().collect();
    // args: [binary, "hook", "rewrite-inline", ...command parts]
    if args.len() < 4 {
        return;
    }
    let cmd = args[3..].join(" ");

    if let Some(rewritten) = file_rewrite::rewrite_candidate(&cmd, &binary) {
        print!("{rewritten}");
        return;
    }

    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
        print!("{cmd}");
        return;
    }

    print!("{cmd}");
}

/// Resolve the lean-ctx executable path for hook command emission and
/// subprocess spawning. Always the **native** OS path: the MSYS/Git-Bash
/// `/c/...` form breaks `CreateProcess` on Windows and cannot be run by
/// PowerShell or cmd (#518). Native `C:/...` runs in PowerShell, cmd *and*
/// Git Bash, so it is the correct universal form for executed commands.
/// (MSYS `/c/...` is only needed for bash *source* lines — see `cli::shell_init`.)
fn resolve_binary() -> String {
    crate::core::portable_binary::resolve_portable_binary()
}

#[cfg(test)]
fn extract_json_field(input: &str, field: &str) -> Option<String> {
    let key = format!("\"{field}\":");
    let key_pos = input.find(&key)?;
    let after_colon = &input[key_pos + key.len()..];
    let trimmed = after_colon.trim_start();
    if !trimmed.starts_with('"') {
        return None;
    }
    let rest = &trimmed[1..];
    let bytes = rest.as_bytes();
    let mut end = 0;
    while end < bytes.len() {
        if bytes[end] == b'\\' && end + 1 < bytes.len() {
            end += 2;
            continue;
        }
        if bytes[end] == b'"' {
            break;
        }
        end += 1;
    }
    if end >= bytes.len() {
        return None;
    }
    let raw = &rest[..end];
    Some(unescape_json_string(raw))
}

/// Single-pass JSON string unescaping (#787).
///
/// Handles \\, \", \n, \t, \r, \/ — the standard JSON escape sequences
/// that agents actually emit in hook payloads. \uXXXX is passed through
/// unchanged (extremely rare in shell commands, not worth the complexity).
#[cfg(test)]
fn unescape_json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('t') => out.push('\t'),
                Some('r') => out.push('\r'),
                Some('"') => out.push('"'),
                Some('/') => out.push('/'),
                Some('\\') | None => out.push('\\'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
            }
        } else {
            out.push(c);
        }
    }
    out
}