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    "grok",
107    "hermes",
108    "jetbrains",
109    "kiro",
110    "neovim",
111    "omp",
112    "openclaw",
113    "opencode",
114    "qoder",
115    "qodercli",
116    "qoderwork",
117    "qwen",
118    "roo",
119    "sublime",
120    "trae",
121    "verdent",
122    "vibe",
123    "vscode",
124    "vscode-insiders",
125    "windsurf",
126    "zed",
127];
128
129fn is_mcp_registered_host(key: &str) -> bool {
130    HOSTS.contains(&key)
131}
132
133#[must_use]
134pub fn egress_routable(agent_key: &str) -> bool {
135    EGRESS_ROUTABLE.contains(&agent_key.to_ascii_lowercase().as_str())
136}
137
138/// Coverage of one host as currently configured on this machine.
139#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
140pub struct HostCoverage {
141    pub host: String,
142    pub level: CoverageLevel,
143    pub tools: ToolIntegration,
144    pub egress_routed: bool,
145    /// What is still outside the gateway at this level, in one sentence.
146    pub gap: &'static str,
147}
148
149/// Classify a host from its static integration, the hook mode actually
150/// installed (if any) and whether its model traffic is routed through the proxy.
151#[must_use]
152pub fn classify(agent_key: &str, installed: Option<HookMode>, egress_routed: bool) -> HostCoverage {
153    let tools = tool_integration(agent_key);
154    let routed = egress_routed && egress_routable(agent_key);
155    let (level, gap) = if routed {
156        let gap = match agent_key.to_ascii_lowercase().as_str() {
157            "claude" | "claude-code" => {
158                "needs an Anthropic API key (Pro/Max sign-in cannot be proxied); non-model calls pass through unchanged"
159            }
160            _ => "non-model calls of the host (sign-in, pairing) pass through unchanged",
161        };
162        (CoverageLevel::Enforced, gap)
163    } else {
164        match (tools, installed) {
165            (ToolIntegration::None, _) => (
166                CoverageLevel::Unsupported,
167                "lean-ctx is not integrated with this host",
168            ),
169            (ToolIntegration::McpOnly, _) | (_, None | Some(HookMode::Mcp)) => (
170                CoverageLevel::NotObservable,
171                "only ctx_* calls are admitted; the host's own tools are invisible",
172            ),
173            (_, Some(HookMode::Hybrid | HookMode::Replace)) => (
174                CoverageLevel::Partial,
175                "host-native file tools reach the model without gateway admission",
176            ),
177        }
178    };
179    HostCoverage {
180        host: agent_key.to_ascii_lowercase(),
181        level,
182        tools,
183        egress_routed: routed,
184        gap,
185    }
186}
187
188/// The best level each integration can reach when fully configured — the
189/// published matrix. `Observed` is never claimed: no installed integration
190/// delivers an all-calls observer without also routing or rewriting.
191#[must_use]
192pub fn achievable(agent_key: &str) -> CoverageLevel {
193    let best_mode = match tool_integration(agent_key) {
194        ToolIntegration::DenyNativeTools => Some(HookMode::Replace),
195        ToolIntegration::ShellRewrite => Some(HookMode::Hybrid),
196        ToolIntegration::McpOnly => Some(HookMode::Mcp),
197        ToolIntegration::None => None,
198    };
199    classify(agent_key, best_mode, true).level
200}
201
202/// The published coverage matrix (`docs/reference/generated/host-coverage.md`).
203#[must_use]
204pub fn matrix_markdown() -> String {
205    use std::fmt::Write as _;
206
207    let mut out = String::from(
208        "# Context gateway coverage by host\n\n\
209         What the context gateway can see and stop for each host when fully set up.\n\
210         `lean-ctx doctor` shows the coverage actually in effect on this machine.\n\n\
211         | Level | Meaning |\n|---|---|\n\
212         | 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\
213         | 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\
214         | not_observable | MCP only: `ctx_*` calls are admitted, the host's own tools are invisible. |\n\
215         | unsupported | No integration. |\n\n\
216         `observed` (sees every call but cannot stop it) is reserved; no current integration delivers it, so none is claimed.\n\n\
217         | Host | Tool integration | Proxy-routable | Best achievable | Still outside the gateway |\n\
218         |---|---|---|---|---|\n",
219    );
220    for host in HOSTS {
221        let best = match tool_integration(host) {
222            ToolIntegration::DenyNativeTools => Some(HookMode::Replace),
223            ToolIntegration::ShellRewrite => Some(HookMode::Hybrid),
224            ToolIntegration::McpOnly => Some(HookMode::Mcp),
225            ToolIntegration::None => None,
226        };
227        let coverage = classify(host, best, true);
228        let tools = match coverage.tools {
229            ToolIntegration::DenyNativeTools => "deny native tools",
230            ToolIntegration::ShellRewrite => "shell rewrite",
231            ToolIntegration::McpOnly => "MCP only",
232            ToolIntegration::None => "none",
233        };
234        let _ = writeln!(
235            out,
236            "| {host} | {tools} | {} | {} | {} |",
237            if egress_routable(host) { "yes" } else { "no" },
238            coverage.level.as_str(),
239            coverage.gap
240        );
241    }
242    out
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn only_proxy_routed_model_traffic_is_called_enforced() {
251        // Replace mode alone still lets native Read through (Edit needs it).
252        assert_eq!(
253            classify("claude", Some(HookMode::Replace), false).level,
254            CoverageLevel::Partial
255        );
256        let routed = classify("claude", Some(HookMode::Replace), true);
257        assert_eq!(routed.level, CoverageLevel::Enforced);
258        assert!(routed.egress_routed);
259        // A routed flag on a host the proxy cannot carry is not believed.
260        let cursor = classify("cursor", Some(HookMode::Replace), true);
261        assert_eq!(cursor.level, CoverageLevel::Partial);
262        assert!(!cursor.egress_routed);
263    }
264
265    #[test]
266    fn missing_hooks_and_unknown_hosts_never_look_covered() {
267        assert_eq!(
268            classify("codex", None, false).level,
269            CoverageLevel::NotObservable
270        );
271        assert_eq!(
272            classify("codewhale", Some(HookMode::Hybrid), false).level,
273            CoverageLevel::NotObservable
274        );
275        assert_eq!(
276            classify("not-a-host", Some(HookMode::Replace), true).level,
277            CoverageLevel::Unsupported
278        );
279    }
280
281    #[test]
282    fn every_registered_host_is_classified() {
283        let mut registry: Vec<String> =
284            crate::core::editor_registry::build_targets(std::path::Path::new("/nonexistent"))
285                .into_iter()
286                .map(|target| target.agent_key)
287                .collect();
288        registry.sort();
289        registry.dedup();
290        assert_eq!(
291            registry, HOSTS,
292            "editor registry and coverage hosts drifted"
293        );
294        for host in HOSTS {
295            assert_ne!(achievable(host), CoverageLevel::Unsupported, "{host}");
296        }
297    }
298
299    #[test]
300    fn the_published_matrix_never_claims_observed() {
301        for host in REPLACE_AGENTS
302            .iter()
303            .chain(HYBRID_AGENTS)
304            .chain(MCP_ONLY_AGENTS)
305        {
306            assert_ne!(achievable(host), CoverageLevel::Observed, "{host}");
307        }
308        assert_eq!(achievable("codex"), CoverageLevel::Enforced);
309        assert_eq!(achievable("cursor"), CoverageLevel::Partial);
310    }
311}