1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ToolIntegration {
47 DenyNativeTools,
49 ShellRewrite,
51 McpOnly,
53 None,
55}
56
57const 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
85pub 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#[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 pub gap: &'static str,
149}
150
151#[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#[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#[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 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 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}