1use crate::core::mcp_catalog::GatewayServer;
16
17#[must_use]
21pub fn wiring_hash(server: &GatewayServer) -> String {
22 let json = serde_json::to_string(server).unwrap_or_default();
23 crate::core::hasher::hash_str(&json)
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum IntegrityStatus {
29 Ok,
31 Drift,
33 Missing,
35 Unpinned,
37}
38
39impl IntegrityStatus {
40 #[must_use]
41 pub fn label(&self) -> &'static str {
42 match self {
43 Self::Ok => "ok",
44 Self::Drift => "DRIFT",
45 Self::Missing => "missing",
46 Self::Unpinned => "unpinned",
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IntegrityFinding {
54 pub name: String,
55 pub status: IntegrityStatus,
56}
57
58#[must_use]
61pub fn verify(
62 installed: &[&super::store::InstalledAddon],
63 servers: &[GatewayServer],
64) -> Vec<IntegrityFinding> {
65 let mut out: Vec<IntegrityFinding> = installed
66 .iter()
67 .map(|addon| {
68 let live = servers.iter().find(|s| s.name == addon.gateway_server);
69 let status = match (&addon.content_hash, live) {
70 (None, _) => IntegrityStatus::Unpinned,
71 (Some(_), None) => IntegrityStatus::Missing,
72 (Some(pinned), Some(server)) => {
73 if *pinned == wiring_hash(server) {
74 IntegrityStatus::Ok
75 } else {
76 IntegrityStatus::Drift
77 }
78 }
79 };
80 IntegrityFinding {
81 name: addon.name.clone(),
82 status,
83 }
84 })
85 .collect();
86 out.sort_by(|a, b| a.name.cmp(&b.name));
87 out
88}
89
90#[must_use]
94pub fn execution_block(
95 addons: &super::policy::AddonsConfig,
96 installed: &[&super::store::InstalledAddon],
97 servers: &[GatewayServer],
98 gateway_server: &str,
99) -> Option<String> {
100 if !addons.enforce_capabilities {
101 return None;
102 }
103 let addon = installed
104 .iter()
105 .find(|addon| addon.gateway_server == gateway_server)?;
106 let status = verify(std::slice::from_ref(addon), servers).pop()?.status;
107 match status {
108 IntegrityStatus::Drift | IntegrityStatus::Missing => Some(format!(
109 "addon `{}` integrity check is {}",
110 addon.name,
111 status.label()
112 )),
113 IntegrityStatus::Ok | IntegrityStatus::Unpinned => None,
114 }
115}
116
117#[must_use]
120pub fn execution_block_for_server(gateway_server: &str) -> Option<String> {
121 let store = super::store::InstalledStore::load();
122 let cfg = crate::core::config::Config::load();
123 execution_block(
124 &cfg.addons,
125 &store.list(),
126 &cfg.gateway.servers,
127 gateway_server,
128 )
129}
130
131#[must_use]
133pub fn verify_all() -> Vec<IntegrityFinding> {
134 let store = super::store::InstalledStore::load();
135 let cfg = crate::core::config::Config::load();
136 verify(&store.list(), &cfg.gateway.servers)
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::core::addons::store::InstalledAddon;
143
144 fn server(name: &str, command: &str) -> GatewayServer {
145 GatewayServer {
146 name: name.into(),
147 command: command.into(),
148 ..Default::default()
149 }
150 }
151
152 fn installed(name: &str, hash: Option<String>) -> InstalledAddon {
153 InstalledAddon {
154 name: name.into(),
155 version: "1.0.0".into(),
156 source: "registry".into(),
157 gateway_server: name.into(),
158 granted_capabilities: None,
159 content_hash: hash,
160 install: None,
161 artifact: None,
162 }
163 }
164
165 #[test]
166 fn hash_is_deterministic_and_wiring_sensitive() {
167 let a = wiring_hash(&server("x", "cmd"));
168 let b = wiring_hash(&server("x", "cmd"));
169 assert_eq!(a, b, "same wiring → same hash");
170 let c = wiring_hash(&server("x", "other"));
171 assert_ne!(a, c, "different command → different hash");
172 }
173
174 #[test]
175 fn matching_hash_is_ok_drift_is_detected() {
176 let srv = server("demo", "demo-mcp");
177 let pinned = wiring_hash(&srv);
178 let addon = installed("demo", Some(pinned));
179
180 let findings = verify(&[&addon], std::slice::from_ref(&srv));
182 assert_eq!(findings[0].status, IntegrityStatus::Ok);
183
184 let tampered = server("demo", "evil-mcp");
186 let findings = verify(&[&addon], &[tampered]);
187 assert_eq!(findings[0].status, IntegrityStatus::Drift);
188 }
189
190 #[test]
191 fn missing_and_unpinned_are_reported() {
192 let pinned = wiring_hash(&server("gone", "x"));
193 let missing = installed("gone", Some(pinned));
194 assert_eq!(verify(&[&missing], &[])[0].status, IntegrityStatus::Missing);
195
196 let legacy = installed("old", None);
197 assert_eq!(
198 verify(&[&legacy], &[server("old", "x")])[0].status,
199 IntegrityStatus::Unpinned
200 );
201 }
202
203 #[test]
204 fn drifted_addon_is_blocked_when_capabilities_are_enforced() {
205 let addon = installed("demo", Some(wiring_hash(&server("demo", "safe-mcp"))));
206 let block = execution_block(
207 &super::super::policy::AddonsConfig::default(),
208 &[&addon],
209 &[server("demo", "tampered-mcp")],
210 "demo",
211 );
212 assert!(block.is_some_and(|reason| reason.contains("DRIFT")));
213 }
214
215 #[test]
216 fn missing_addon_is_blocked_when_capabilities_are_enforced() {
217 let addon = installed("gone", Some(wiring_hash(&server("gone", "gone-mcp"))));
218 let block = execution_block(
219 &super::super::policy::AddonsConfig::default(),
220 &[&addon],
221 &[],
222 "gone",
223 );
224 assert!(block.is_some_and(|reason| reason.contains("missing")));
225 }
226
227 #[test]
228 fn unpinned_addon_is_warned_but_allowed() {
229 let addon = installed("legacy", None);
230 let servers = [server("legacy", "legacy-mcp")];
231 assert_eq!(
232 verify(&[&addon], &servers)[0].status,
233 IntegrityStatus::Unpinned
234 );
235 assert_eq!(
236 execution_block(
237 &super::super::policy::AddonsConfig::default(),
238 &[&addon],
239 &servers,
240 "legacy",
241 ),
242 None
243 );
244 }
245
246 #[test]
247 fn findings_are_name_sorted() {
248 let b = installed("b", None);
249 let a = installed("a", None);
250 let findings = verify(&[&b, &a], &[]);
251 assert_eq!(findings[0].name, "a");
252 assert_eq!(findings[1].name, "b");
253 }
254}