Skip to main content

lean_ctx/core/
host_coverage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Host coverage — what the context gateway can actually see and stop per host.
3//!
4//! One source for `doctor`, the generated coverage matrix and the gateway
5//! status line. It never claims more than the integration delivers:
6//!
7//! - **Enforced:** the host's model traffic flows through the lean-ctx proxy,
8//!   so egress admission checks every byte before it leaves the device. If
9//!   the proxy is down the request fails — fail-closed by construction.
10//! - **Observed:** a hook sees every tool call but cannot stop or change it.
11//! - **Partial:** lean-ctx admits its own tools and rewrites shell commands,
12//!   but host-native file tools still reach the model unchecked. Rewrite
13//!   hooks fail open on purpose (compression is best effort).
14//! - **NotObservable:** MCP only — `ctx_*` calls are admitted, nothing else is
15//!   visible to lean-ctx.
16//! - **Unsupported:** no integration exists.
17
18use crate::core::hook_mode::{HYBRID_AGENTS, HookMode, MCP_ONLY_AGENTS, REPLACE_AGENTS};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum CoverageLevel {
23    Unsupported,
24    NotObservable,
25    Partial,
26    Observed,
27    Enforced,
28}
29
30impl CoverageLevel {
31    #[must_use]
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Enforced => "enforced",
35            Self::Observed => "observed",
36            Self::Partial => "partial",
37            Self::NotObservable => "not_observable",
38            Self::Unsupported => "unsupported",
39        }
40    }
41}
42
43/// What a host's own tooling lets lean-ctx do at the tool layer.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ToolIntegration {
47    /// Native tools can be denied (`permissions.deny` / pre-tool deny hook).
48    DenyNativeTools,
49    /// Pre-tool hooks rewrite shell commands; native tools stay available.
50    ShellRewrite,
51    /// MCP server registration only.
52    McpOnly,
53    /// Not integrated.
54    None,
55}
56
57/// Hosts whose model traffic `lean-ctx proxy enable` can route through the
58/// proxy (`proxy_setup`), which is what makes egress admission complete.
59/// `egress_routed_hosts` can only confirm routing for claude, codex and grok;
60/// pi and commandcode are routable too but stay unconfirmed (never overclaimed).
61const EGRESS_ROUTABLE: &[&str] = &[
62    "claude",
63    "claude-code",
64    "codex",
65    "grok",
66    "pi",
67    "commandcode",
68];
69
70#[must_use]
71pub fn tool_integration(agent_key: &str) -> ToolIntegration {
72    let key = agent_key.to_ascii_lowercase();
73    let key = key.as_str();
74    if REPLACE_AGENTS.contains(&key) {
75        ToolIntegration::DenyNativeTools
76    } else if HYBRID_AGENTS.contains(&key) {
77        ToolIntegration::ShellRewrite
78    } else if MCP_ONLY_AGENTS.contains(&key) || is_mcp_registered_host(key) {
79        ToolIntegration::McpOnly
80    } else {
81        ToolIntegration::None
82    }
83}
84
85/// Every host `lean-ctx setup` registers (the editor registry's agent keys).
86/// Kept static so classification never spawns detection subprocesses; a test
87/// pins it to the registry so a new host cannot go unclassified.
88pub const HOSTS: &[&str] = &[
89    "aider",
90    "amazonq",
91    "amp",
92    "antigravity",
93    "antigravity-cli",
94    "augment",
95    "claude",
96    "cline",
97    "codebuddy",
98    "codewhale",
99    "codex",
100    "continue",
101    "copilot",
102    "crush",
103    "cursor",
104    "emacs",
105    "gemini",
106    "goose",
107    "grok",
108    "hermes",
109    "jetbrains",
110    "kiro",
111    "muse",
112    "neovim",
113    "omp",
114    "openclaw",
115    "opencode",
116    "qoder",
117    "qodercli",
118    "qoderwork",
119    "qwen",
120    "roo",
121    "sublime",
122    "trae",
123    "verdent",
124    "vibe",
125    "vscode",
126    "vscode-insiders",
127    "windsurf",
128    "zed",
129];
130
131fn is_mcp_registered_host(key: &str) -> bool {
132    HOSTS.contains(&key)
133}
134
135#[must_use]
136pub fn egress_routable(agent_key: &str) -> bool {
137    EGRESS_ROUTABLE.contains(&agent_key.to_ascii_lowercase().as_str())
138}
139
140/// Coverage of one host as currently configured on this machine.
141#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
142pub struct HostCoverage {
143    pub host: String,
144    pub level: CoverageLevel,
145    pub tools: ToolIntegration,
146    pub egress_routed: bool,
147    /// What is still outside the gateway at this level, in one sentence.
148    pub gap: &'static str,
149}
150
151/// Classify a host from its static integration, the hook mode actually
152/// installed (if any) and whether its model traffic is routed through the proxy.
153#[must_use]
154pub fn classify(agent_key: &str, installed: Option<HookMode>, egress_routed: bool) -> HostCoverage {
155    let tools = tool_integration(agent_key);
156    let routed = egress_routed && egress_routable(agent_key);
157    let (level, gap) = if routed {
158        let gap = match agent_key.to_ascii_lowercase().as_str() {
159            "claude" | "claude-code" => {
160                "needs an Anthropic API key (Pro/Max sign-in cannot be proxied); non-model calls pass through unchanged"
161            }
162            _ => "non-model calls of the host (sign-in, pairing) pass through unchanged",
163        };
164        (CoverageLevel::Enforced, gap)
165    } else {
166        match (tools, installed) {
167            (ToolIntegration::None, _) => (
168                CoverageLevel::Unsupported,
169                "lean-ctx is not integrated with this host",
170            ),
171            (ToolIntegration::McpOnly, _) | (_, None | Some(HookMode::Mcp)) => (
172                CoverageLevel::NotObservable,
173                "only ctx_* calls are admitted; the host's own tools are invisible",
174            ),
175            (_, Some(HookMode::Hybrid | HookMode::Replace)) => (
176                CoverageLevel::Partial,
177                "host-native file tools reach the model without gateway admission",
178            ),
179        }
180    };
181    HostCoverage {
182        host: agent_key.to_ascii_lowercase(),
183        level,
184        tools,
185        egress_routed: routed,
186        gap,
187    }
188}
189
190/// The best level each integration can reach when fully configured — the
191/// published matrix. `Observed` is never claimed: no installed integration
192/// delivers an all-calls observer without also routing or rewriting.
193#[must_use]
194pub fn achievable(agent_key: &str) -> CoverageLevel {
195    let best_mode = match tool_integration(agent_key) {
196        ToolIntegration::DenyNativeTools => Some(HookMode::Replace),
197        ToolIntegration::ShellRewrite => Some(HookMode::Hybrid),
198        ToolIntegration::McpOnly => Some(HookMode::Mcp),
199        ToolIntegration::None => None,
200    };
201    classify(agent_key, best_mode, true).level
202}
203
204/// The published coverage matrix (`docs/reference/generated/host-coverage.md`).
205#[must_use]
206pub fn matrix_markdown() -> String {
207    use std::fmt::Write as _;
208
209    let mut out = String::from(
210        "# Context gateway coverage by host\n\n\
211         What the context gateway can see and stop for each host when fully set up.\n\
212         `lean-ctx doctor` shows the coverage actually in effect on this machine.\n\n\
213         | Level | Meaning |\n|---|---|\n\
214         | enforced | Model traffic flows through the lean-ctx proxy; egress admission checks every byte before it leaves. A stopped proxy fails the request (fail-closed). |\n\
215         | partial | lean-ctx tools are admitted and shell commands rewritten; host-native file tools still reach the model unchecked. Rewrite hooks fail open by design. |\n\
216         | not_observable | MCP only: `ctx_*` calls are admitted, the host's own tools are invisible. |\n\
217         | unsupported | No integration. |\n\n\
218         `observed` (sees every call but cannot stop it) is reserved; no current integration delivers it, so none is claimed.\n\n\
219         | Host | Tool integration | Proxy-routable | Best achievable | Still outside the gateway |\n\
220         |---|---|---|---|---|\n",
221    );
222    for host in HOSTS {
223        let best = match tool_integration(host) {
224            ToolIntegration::DenyNativeTools => Some(HookMode::Replace),
225            ToolIntegration::ShellRewrite => Some(HookMode::Hybrid),
226            ToolIntegration::McpOnly => Some(HookMode::Mcp),
227            ToolIntegration::None => None,
228        };
229        let coverage = classify(host, best, true);
230        let tools = match coverage.tools {
231            ToolIntegration::DenyNativeTools => "deny native tools",
232            ToolIntegration::ShellRewrite => "shell rewrite",
233            ToolIntegration::McpOnly => "MCP only",
234            ToolIntegration::None => "none",
235        };
236        let _ = writeln!(
237            out,
238            "| {host} | {tools} | {} | {} | {} |",
239            if egress_routable(host) { "yes" } else { "no" },
240            coverage.level.as_str(),
241            coverage.gap
242        );
243    }
244    out
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn only_proxy_routed_model_traffic_is_called_enforced() {
253        // Replace mode alone still lets native Read through (Edit needs it).
254        assert_eq!(
255            classify("claude", Some(HookMode::Replace), false).level,
256            CoverageLevel::Partial
257        );
258        let routed = classify("claude", Some(HookMode::Replace), true);
259        assert_eq!(routed.level, CoverageLevel::Enforced);
260        assert!(routed.egress_routed);
261        // A routed flag on a host the proxy cannot carry is not believed.
262        let cursor = classify("cursor", Some(HookMode::Replace), true);
263        assert_eq!(cursor.level, CoverageLevel::Partial);
264        assert!(!cursor.egress_routed);
265    }
266
267    #[test]
268    fn missing_hooks_and_unknown_hosts_never_look_covered() {
269        assert_eq!(
270            classify("codex", None, false).level,
271            CoverageLevel::NotObservable
272        );
273        assert_eq!(
274            classify("codewhale", Some(HookMode::Hybrid), false).level,
275            CoverageLevel::NotObservable
276        );
277        assert_eq!(
278            classify("not-a-host", Some(HookMode::Replace), true).level,
279            CoverageLevel::Unsupported
280        );
281    }
282
283    #[test]
284    fn every_registered_host_is_classified() {
285        let mut registry: Vec<String> =
286            crate::core::editor_registry::build_targets(std::path::Path::new("/nonexistent"))
287                .into_iter()
288                .map(|target| target.agent_key)
289                .collect();
290        registry.sort();
291        registry.dedup();
292        assert_eq!(
293            registry, HOSTS,
294            "editor registry and coverage hosts drifted"
295        );
296        for host in HOSTS {
297            assert_ne!(achievable(host), CoverageLevel::Unsupported, "{host}");
298        }
299    }
300
301    #[test]
302    fn the_published_matrix_never_claims_observed() {
303        for host in REPLACE_AGENTS
304            .iter()
305            .chain(HYBRID_AGENTS)
306            .chain(MCP_ONLY_AGENTS)
307        {
308            assert_ne!(achievable(host), CoverageLevel::Observed, "{host}");
309        }
310        assert_eq!(achievable("codex"), CoverageLevel::Enforced);
311        assert_eq!(achievable("cursor"), CoverageLevel::Partial);
312    }
313}