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}