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 let cfg = crate::core::config::Config::load();
148 // A global choice first, then the host's own pin (`init --agent <host>
149 // --mode <m>` writes `[governor] pinned.<host>`): a setup refresh or an
150 // update re-renders exactly the mode the user chose for that host.
151 let host = if agent_key.eq_ignore_ascii_case("claude-code") {
152 "claude"
153 } else {
154 agent_key
155 };
156 let chosen = cfg.hook_mode_override().or_else(|| {
157 cfg.governor
158 .pinned
159 .get(host)
160 .and_then(|m| HookMode::from_str_loose(m))
161 });
162 if let Some(chosen) = chosen {
163 return if is_deny_suppressed() {
164 chosen.cap_at_hybrid()
165 } else {
166 chosen
167 };
168 }
169 if REPLACE_AGENTS.contains(&agent_key) || HYBRID_AGENTS.contains(&agent_key) {
170 HookMode::Hybrid
171 } else {
172 HookMode::Mcp
173 }
174}
175
176/// True when native-tool denial is in effect: the user chose Replace and has
177/// not suppressed denials (#1037). Deny hooks consult this at call time, so a
178/// hook entry written while Replace was the default stops blocking the moment
179/// the binary updates — no settings rewrite has to happen first.
180#[must_use]
181pub fn replace_mode_chosen() -> bool {
182 crate::core::config::Config::load().hook_mode_override() == Some(HookMode::Replace)
183 && !is_deny_suppressed()
184}
185
186/// Whether Claude Code (and CodeBuddy, which shares its contract) keeps native
187/// Grep/Glob out of the model's tool list via `permissions.deny`.
188///
189/// Unlike a hook denial this costs no error and no retry: Claude Code drops a
190/// denied tool from the list, so the model never calls it. And it keeps
191/// searches on the compressed path — native Glob cannot be redirected (#1033).
192/// Measured 2026-10-13 over 30 days: 0 Grep/Glob calls and 0 denials in 380
193/// Claude sessions with the list, while native Glob returned ~12× the tokens
194/// of `ctx_glob` for the same patterns. So the default (auto) mode keeps it;
195/// an explicit `hook_mode = "hybrid"`/`"mcp"`, `shadow_mode = false` or
196/// `LEAN_CTX_DISABLED` give the tools back.
197#[must_use]
198pub fn claude_hides_native_search(mode: HookMode) -> bool {
199 match mode {
200 HookMode::Replace => true,
201 HookMode::Hybrid => {
202 crate::core::config::Config::load()
203 .hook_mode_override()
204 .is_none()
205 && !is_deny_suppressed()
206 }
207 HookMode::Mcp => false,
208 }
209}
210
211/// True when the user explicitly opted out of deny-list injection.
212fn is_deny_suppressed() -> bool {
213 if std::env::var("LEAN_CTX_DISABLED").is_ok() {
214 return true;
215 }
216 if matches!(std::env::var("LEAN_CTX_SHADOW_MODE"), Ok(v) if v.trim() == "false" || v.trim() == "0")
217 {
218 return true;
219 }
220 if matches!(std::env::var("LEAN_CTX_HEAL"), Ok(v) if v.trim().eq_ignore_ascii_case("off") || v.trim() == "0")
221 {
222 return true;
223 }
224 let cfg = crate::core::config::Config::load();
225 !cfg.shadow_mode
226}
227
228/// Env key/value pairs for the lean-ctx MCP server entry written into agent
229/// configs (Codex TOML + the JSON agents).
230///
231/// Deliberately does NOT pin `LEAN_CTX_DATA_DIR`: lean-ctx auto-detects its
232/// per-category dirs (config/data/state/cache) at runtime, and pinning the data
233/// dir would set that var in the server's environment, forcing single-dir mode
234/// and collapsing config/state/cache onto the data dir — defeating the XDG split
235/// (GH #408).
236///
237/// Deliberately does NOT pin a project either. Agent configs are user-global:
238/// one `~/.codex/config.toml` serves every Codex session on the machine. The
239/// #403 build copied the *installing* process's `LEAN_CTX_PROJECT_ROOT` /
240/// `LEAN_CTX_EXTRA_ROOTS` in here, so whichever project last ran `setup`,
241/// `doctor --fix` or an MCP-start hook refresh became the project of every
242/// later session — and each switch needed a config edit plus an MCP restart.
243/// The server derives its project per session instead (host roots, host env,
244/// working directory) and reads `extra_roots` from config.toml itself; stale
245/// pins are removed by `hooks::project_pins`.
246///
247/// Single source of truth so every agent installer stays consistent; it is
248/// currently empty on purpose.
249pub fn mcp_server_env_pairs() -> Vec<(String, String)> {
250 Vec::new()
251}
252
253/// The MCP server env block as a JSON object, for the JSON-config agents.
254pub fn mcp_server_env_json() -> serde_json::Value {
255 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
256 .into_iter()
257 .map(|(k, v)| (k, serde_json::Value::String(v)))
258 .collect();
259 serde_json::Value::Object(map)
260}