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 "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#[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 pub gap: &'static str,
147}
148
149#[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#[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#[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 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 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}