Skip to main content

lean_ctx/core/
hook_mode.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Hook-mode policy shared by the engine and the agent-hook installers.
3//!
4//! Which integration mode an agent gets (`Mcp` / `Hybrid` / `Replace`), the
5//! agent lists behind that choice, and the MCP server env block every
6//! installer writes. Pure policy over `Config` and the environment: the
7//! installers in `hooks` consume it, and engine modules (config, telemetry,
8//! host coverage, context overhead) read it without depending on `hooks`.
9
10/// Controls how hooks instruct agents to access lean-ctx functionality.
11///
12/// * `Mcp` — MCP server only (extension/plugin-based agents without reliable shell).
13/// * `Hybrid` — MCP server + shell hooks for command compression (best of both).
14/// * `Replace` — Native Read/Grep/Glob/Shell are **denied**; lean-ctx MCP tools are
15///   the only path. Eliminates tool drift entirely — no agent compliance needed.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum HookMode {
19    #[default]
20    Mcp,
21    Hybrid,
22    Replace,
23}
24
25impl std::fmt::Display for HookMode {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::Mcp => write!(f, "MCP"),
29            Self::Hybrid => write!(f, "Hybrid"),
30            Self::Replace => write!(f, "Replace"),
31        }
32    }
33}
34
35impl HookMode {
36    pub fn from_str_loose(s: &str) -> Option<Self> {
37        match s.to_lowercase().replace('-', "").as_str() {
38            "mcp" => Some(Self::Mcp),
39            "hybrid" => Some(Self::Hybrid),
40            "replace" => Some(Self::Replace),
41            _ => None,
42        }
43    }
44
45    /// Downgrade Replace → Hybrid; leave Hybrid/Mcp unchanged.
46    pub fn cap_at_hybrid(self) -> Self {
47        if matches!(self, Self::Replace) {
48            Self::Hybrid
49        } else {
50            self
51        }
52    }
53
54    pub fn description(&self) -> &'static str {
55        match self {
56            Self::Mcp => "MCP server only (extension/plugin-based agents without reliable shell)",
57            Self::Hybrid => "MCP server + shell hooks for command compression (best of both)",
58            Self::Replace => {
59                "Native tools denied — lean-ctx MCP is the only path (zero tool drift)"
60            }
61        }
62    }
63}
64
65/// Agents with reliable shell + hook infrastructure that *can* run Replace mode
66/// (native tools denied, lean-ctx MCP is the only path). These agents have
67/// either `permissions.deny` support or PreToolUse deny-hook capability.
68///
69/// Replace is opt-in (`hook_mode = "replace"`): denying native tools made
70/// agents fail and retry instead of working, so these agents default to Hybrid
71/// like every other hook-capable agent.
72pub const REPLACE_AGENTS: &[&str] = &[
73    "cursor",
74    "claude",
75    "claude-code",
76    "codebuddy",
77    "codex",
78    "windsurf",
79    "opencode",
80    "gemini",
81];
82
83/// Agents that get Hybrid mode (MCP + shell hooks) because they lack reliable
84/// deny infrastructure but do have shell hooks for command compression.
85pub const HYBRID_AGENTS: &[&str] = &[
86    "cursor",
87    "gemini",
88    "codex",
89    "claude",
90    "claude-code",
91    "crush",
92    "hermes",
93    "opencode",
94    "openclaw",
95    "pi",
96    "omp",
97    "qoder",
98    "qodercli",
99    "windsurf",
100    "amp",
101    "cline",
102    "roo",
103    "copilot",
104    "kiro",
105    "qwen",
106    "trae",
107    "antigravity",
108    "antigravity-cli",
109    "amazonq",
110    "verdent",
111];
112
113/// First-class agents that get MCP registration only — no shell hooks, because
114/// their hook surface does not (yet) carry lean-ctx's contract.
115///
116/// CodeWhale (#1402) is the first entry: its hooks are TUI-only and follow
117/// observer/steering semantics rather than the pre-tool rewrite/deny contract
118/// every `HYBRID_AGENTS` entry relies on, so installing lean-ctx's shell hooks
119/// there would ship a script whose behaviour we cannot honestly predict.
120/// Muse Code (#2198): its PreToolUse output schema is closed and an `allow`
121/// never skips approval, so a rewrite would add prompts instead of saving
122/// tokens; it gets the settings.json MCP entry only.
123/// Membership here does not enable any hook install — it only marks the agent
124/// as genuinely supported by `init --agent` / `setup` so `wrap` can say so.
125///
126/// Goose: its hooks are plugin hooks (`~/.agents/plugins/<name>/hooks`), not
127/// a surface LeanCTX installs into yet; the `extensions` entry is the setup.
128pub const MCP_ONLY_AGENTS: &[&str] = &["codewhale", "goose", "muse"];
129
130/// True when `agent` is integrated via `init --agent` / `setup` (hooks, rules,
131/// MCP registration). GH #1520: used by `wrap`'s error path to point users of
132/// a supported-but-not-proxy-wrappable agent (e.g. opencode) at the working
133/// command instead of a bare "unsupported agent".
134pub fn is_supported_agent(agent: &str) -> bool {
135    let key = agent.to_ascii_lowercase();
136    REPLACE_AGENTS.contains(&key.as_str())
137        || HYBRID_AGENTS.contains(&key.as_str())
138        || MCP_ONLY_AGENTS.contains(&key.as_str())
139}
140
141/// Auto-detect the best hook mode for a given agent key.
142///
143/// Priority: disabled/shadow_mode cap > config override > Hybrid > Mcp
144/// - Replace: native tools denied, MCP-only path — only when the user chose it
145///   (`hook_mode = "replace"`, `LEAN_CTX_HOOK_MODE=replace`, `harden --hard`)
146/// - Hybrid: MCP + shell hooks; native tools keep working, known commands
147///   are rewritten through lean-ctx (default for every hook-capable agent)
148/// - Mcp: MCP server only (no shell hooks available)
149///
150/// `LEAN_CTX_DISABLED=1` or `shadow_mode = false` cap the mode at Hybrid so
151/// the deny-list is never injected when the user opted out (#1037).
152pub fn recommend_hook_mode(agent_key: &str) -> HookMode {
153    let cfg = crate::core::config::Config::load();
154    // A global choice first, then the host's own pin (`init --agent <host>
155    // --mode <m>` writes `[governor] pinned.<host>`): a setup refresh or an
156    // update re-renders exactly the mode the user chose for that host.
157    let host = if agent_key.eq_ignore_ascii_case("claude-code") {
158        "claude"
159    } else {
160        agent_key
161    };
162    let chosen = cfg.hook_mode_override().or_else(|| {
163        cfg.governor
164            .pinned
165            .get(host)
166            .and_then(|m| HookMode::from_str_loose(m))
167    });
168    if let Some(chosen) = chosen {
169        return if is_deny_suppressed() {
170            chosen.cap_at_hybrid()
171        } else {
172            chosen
173        };
174    }
175    if REPLACE_AGENTS.contains(&agent_key) || HYBRID_AGENTS.contains(&agent_key) {
176        HookMode::Hybrid
177    } else {
178        HookMode::Mcp
179    }
180}
181
182/// True when native-tool denial is in effect: the user chose Replace and has
183/// not suppressed denials (#1037). Deny hooks consult this at call time, so a
184/// hook entry written while Replace was the default stops blocking the moment
185/// the binary updates — no settings rewrite has to happen first.
186#[must_use]
187pub fn replace_mode_chosen() -> bool {
188    crate::core::config::Config::load().hook_mode_override() == Some(HookMode::Replace)
189        && !is_deny_suppressed()
190}
191
192/// Whether Claude Code (and CodeBuddy, which shares its contract) keeps native
193/// Grep/Glob out of the model's tool list via `permissions.deny`.
194///
195/// Unlike a hook denial this costs no error and no retry: Claude Code drops a
196/// denied tool from the list, so the model never calls it. And it keeps
197/// searches on the compressed path — native Glob cannot be redirected (#1033).
198/// Measured 2026-10-13 over 30 days: 0 Grep/Glob calls and 0 denials in 380
199/// Claude sessions with the list, while native Glob returned ~12× the tokens
200/// of `ctx_glob` for the same patterns. So the default (auto) mode keeps it;
201/// an explicit `hook_mode = "hybrid"`/`"mcp"`, `shadow_mode = false` or
202/// `LEAN_CTX_DISABLED` give the tools back.
203#[must_use]
204pub fn claude_hides_native_search(mode: HookMode) -> bool {
205    match mode {
206        HookMode::Replace => true,
207        HookMode::Hybrid => {
208            crate::core::config::Config::load()
209                .hook_mode_override()
210                .is_none()
211                && !is_deny_suppressed()
212        }
213        HookMode::Mcp => false,
214    }
215}
216
217/// True when the user explicitly opted out of deny-list injection.
218fn is_deny_suppressed() -> bool {
219    if std::env::var("LEAN_CTX_DISABLED").is_ok() {
220        return true;
221    }
222    if matches!(std::env::var("LEAN_CTX_SHADOW_MODE"), Ok(v) if v.trim() == "false" || v.trim() == "0")
223    {
224        return true;
225    }
226    if matches!(std::env::var("LEAN_CTX_HEAL"), Ok(v) if v.trim().eq_ignore_ascii_case("off") || v.trim() == "0")
227    {
228        return true;
229    }
230    let cfg = crate::core::config::Config::load();
231    !cfg.shadow_mode
232}
233
234/// Env key/value pairs for the lean-ctx MCP server entry written into agent
235/// configs (Codex TOML + the JSON agents).
236///
237/// Deliberately does NOT pin `LEAN_CTX_DATA_DIR`: lean-ctx auto-detects its
238/// per-category dirs (config/data/state/cache) at runtime, and pinning the data
239/// dir would set that var in the server's environment, forcing single-dir mode
240/// and collapsing config/state/cache onto the data dir — defeating the XDG split
241/// (GH #408).
242///
243/// Deliberately does NOT pin a project either. Agent configs are user-global:
244/// one `~/.codex/config.toml` serves every Codex session on the machine. The
245/// #403 build copied the *installing* process's `LEAN_CTX_PROJECT_ROOT` /
246/// `LEAN_CTX_EXTRA_ROOTS` in here, so whichever project last ran `setup`,
247/// `doctor --fix` or an MCP-start hook refresh became the project of every
248/// later session — and each switch needed a config edit plus an MCP restart.
249/// The server derives its project per session instead (host roots, host env,
250/// working directory) and reads `extra_roots` from config.toml itself; stale
251/// pins are removed by `hooks::project_pins`.
252///
253/// Single source of truth so every agent installer stays consistent; it is
254/// currently empty on purpose.
255pub fn mcp_server_env_pairs() -> Vec<(String, String)> {
256    Vec::new()
257}
258
259/// The MCP server env block as a JSON object, for the JSON-config agents.
260pub fn mcp_server_env_json() -> serde_json::Value {
261    let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
262        .into_iter()
263        .map(|(k, v)| (k, serde_json::Value::String(v)))
264        .collect();
265    serde_json::Value::Object(map)
266}