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/// Membership here does not enable any hook install — it only marks the agent
121/// as genuinely supported by `init --agent` / `setup` so `wrap` can say so.
122pub const MCP_ONLY_AGENTS: &[&str] = &["codewhale"];
123
124/// True when `agent` is integrated via `init --agent` / `setup` (hooks, rules,
125/// MCP registration). GH #1520: used by `wrap`'s error path to point users of
126/// a supported-but-not-proxy-wrappable agent (e.g. opencode) at the working
127/// command instead of a bare "unsupported agent".
128pub fn is_supported_agent(agent: &str) -> bool {
129 let key = agent.to_ascii_lowercase();
130 REPLACE_AGENTS.contains(&key.as_str())
131 || HYBRID_AGENTS.contains(&key.as_str())
132 || MCP_ONLY_AGENTS.contains(&key.as_str())
133}
134
135/// Auto-detect the best hook mode for a given agent key.
136///
137/// Priority: disabled/shadow_mode cap > config override > Hybrid > Mcp
138/// - Replace: native tools denied, MCP-only path — only when the user chose it
139/// (`hook_mode = "replace"`, `LEAN_CTX_HOOK_MODE=replace`, `harden --hard`)
140/// - Hybrid: MCP + shell hooks; native tools keep working, known commands
141/// are rewritten through lean-ctx (default for every hook-capable agent)
142/// - Mcp: MCP server only (no shell hooks available)
143///
144/// `LEAN_CTX_DISABLED=1` or `shadow_mode = false` cap the mode at Hybrid so
145/// the deny-list is never injected when the user opted out (#1037).
146pub fn recommend_hook_mode(agent_key: &str) -> HookMode {
147 if let Some(override_mode) = crate::core::config::Config::load().hook_mode_override() {
148 return if is_deny_suppressed() {
149 override_mode.cap_at_hybrid()
150 } else {
151 override_mode
152 };
153 }
154 if REPLACE_AGENTS.contains(&agent_key) || HYBRID_AGENTS.contains(&agent_key) {
155 HookMode::Hybrid
156 } else {
157 HookMode::Mcp
158 }
159}
160
161/// True when native-tool denial is in effect: the user chose Replace and has
162/// not suppressed denials (#1037). Deny hooks consult this at call time, so a
163/// hook entry written while Replace was the default stops blocking the moment
164/// the binary updates — no settings rewrite has to happen first.
165#[must_use]
166pub fn replace_mode_chosen() -> bool {
167 crate::core::config::Config::load().hook_mode_override() == Some(HookMode::Replace)
168 && !is_deny_suppressed()
169}
170
171/// Whether Claude Code (and CodeBuddy, which shares its contract) keeps native
172/// Grep/Glob out of the model's tool list via `permissions.deny`.
173///
174/// Unlike a hook denial this costs no error and no retry: Claude Code drops a
175/// denied tool from the list, so the model never calls it. And it keeps
176/// searches on the compressed path — native Glob cannot be redirected (#1033).
177/// Measured 2026-10-13 over 30 days: 0 Grep/Glob calls and 0 denials in 380
178/// Claude sessions with the list, while native Glob returned ~12× the tokens
179/// of `ctx_glob` for the same patterns. So the default (auto) mode keeps it;
180/// an explicit `hook_mode = "hybrid"`/`"mcp"`, `shadow_mode = false` or
181/// `LEAN_CTX_DISABLED` give the tools back.
182#[must_use]
183pub fn claude_hides_native_search(mode: HookMode) -> bool {
184 match mode {
185 HookMode::Replace => true,
186 HookMode::Hybrid => {
187 crate::core::config::Config::load()
188 .hook_mode_override()
189 .is_none()
190 && !is_deny_suppressed()
191 }
192 HookMode::Mcp => false,
193 }
194}
195
196/// True when the user explicitly opted out of deny-list injection.
197fn is_deny_suppressed() -> bool {
198 if std::env::var("LEAN_CTX_DISABLED").is_ok() {
199 return true;
200 }
201 if matches!(std::env::var("LEAN_CTX_SHADOW_MODE"), Ok(v) if v.trim() == "false" || v.trim() == "0")
202 {
203 return true;
204 }
205 if matches!(std::env::var("LEAN_CTX_HEAL"), Ok(v) if v.trim().eq_ignore_ascii_case("off") || v.trim() == "0")
206 {
207 return true;
208 }
209 let cfg = crate::core::config::Config::load();
210 !cfg.shadow_mode
211}
212
213/// Env key/value pairs for the lean-ctx MCP server entry written into agent
214/// configs (Codex TOML + the JSON agents).
215///
216/// Deliberately does NOT pin `LEAN_CTX_DATA_DIR`: lean-ctx auto-detects its
217/// per-category dirs (config/data/state/cache) at runtime, and pinning the data
218/// dir would set that var in the server's environment, forcing single-dir mode
219/// and collapsing config/state/cache onto the data dir — defeating the XDG split
220/// (GH #408).
221///
222/// Deliberately does NOT pin a project either. Agent configs are user-global:
223/// one `~/.codex/config.toml` serves every Codex session on the machine. The
224/// #403 build copied the *installing* process's `LEAN_CTX_PROJECT_ROOT` /
225/// `LEAN_CTX_EXTRA_ROOTS` in here, so whichever project last ran `setup`,
226/// `doctor --fix` or an MCP-start hook refresh became the project of every
227/// later session — and each switch needed a config edit plus an MCP restart.
228/// The server derives its project per session instead (host roots, host env,
229/// working directory) and reads `extra_roots` from config.toml itself; stale
230/// pins are removed by `hooks::project_pins`.
231///
232/// Single source of truth so every agent installer stays consistent; it is
233/// currently empty on purpose.
234pub fn mcp_server_env_pairs() -> Vec<(String, String)> {
235 Vec::new()
236}
237
238/// The MCP server env block as a JSON object, for the JSON-config agents.
239pub fn mcp_server_env_json() -> serde_json::Value {
240 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
241 .into_iter()
242 .map(|(k, v)| (k, serde_json::Value::String(v)))
243 .collect();
244 serde_json::Value::Object(map)
245}