Skip to main content

agentsec_core/registry/
mod.rs

1//! Known-good MCP server registry.
2//!
3//! A registry is a mapping `name → source URL` for MCP servers the user
4//! has signalled are safe (because they came from a curated public list,
5//! a personal allowlist, or a cached pull). It feeds the BlackList check
6//! in [`crate::scan::unknown::classify`]:
7//!
8//! - **Exact match** of an installed MCP server name against a registry
9//!   entry ⇒ `KnownGood`.
10//! - **Levenshtein distance ≤ 2** to some entry (and not identical) ⇒
11//!   `Typosquat` — high false-positive risk if the user named their own
12//!   server something close, but worth surfacing for review.
13//! - **No match** ⇒ `Unknown` — neutral verdict, just "AgentSec doesn't
14//!   recognise this name".
15//!
16//! ## Source chain (demo-level)
17//!
18//! Phase 0 is a stub for the Service-side signature DB that Phase 1+
19//! would build. For now the resolution order is:
20//!
21//! 1. **Cache** — `<paths.home>/registry.json`, if present.
22//! 2. **Network** — fetch from [`DEFAULT_SOURCE_URL`] (or a caller-provided
23//!    URL) when explicitly requested. This is opt-in to keep `scan` and
24//!    `blacklist` runs deterministic by default.
25//! 3. **Builtin fallback** — a hardcoded list of well-known
26//!    `modelcontextprotocol/servers` entries. Always available, never
27//!    fails.
28//!
29//! The wire format for the network endpoint is a flat JSON array of
30//! `{"name": "...", "source": "..."}` objects.
31
32use std::collections::HashMap;
33use std::fs;
34use std::path::PathBuf;
35use std::time::Duration;
36
37use serde::{Deserialize, Serialize};
38
39use crate::Paths;
40use crate::error::{Error, Result};
41
42/// Public demo endpoint. Open-source / read-only; the builtin fallback
43/// keeps the binary working when this URL is unreachable.
44pub const DEFAULT_SOURCE_URL: &str =
45    "https://raw.githubusercontent.com/modelcontextprotocol/servers/main/.well-known/registry.json";
46
47const FETCH_TIMEOUT_SECS: u64 = 10;
48const USER_AGENT: &str = concat!("agentsec-registry/", env!("CARGO_PKG_VERSION"));
49
50/// One registry entry: a known-good MCP server name and where it came from.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct RegistryEntry {
53    /// Server name as it would appear under `mcpServers.<name>` in
54    /// `.mcp.json` or `.claude.json`.
55    pub name: String,
56    /// Free-form provenance string (URL, "builtin", filename, etc.). Echoed
57    /// back through [`crate::scan::unknown::UnknownVerdict::reason`] so the
58    /// user can audit why a name was approved.
59    pub source: String,
60}
61
62/// In-memory known-good registry. Cheap to clone (entries live in a
63/// `HashMap`, keys are short ASCII names).
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct Registry {
66    /// All entries keyed by `name` for O(1) exact-match lookup.
67    pub entries: HashMap<String, RegistryEntry>,
68}
69
70impl Registry {
71    /// Hardcoded fallback list — well-known `modelcontextprotocol/servers`
72    /// entries. Always succeeds, never touches the network or filesystem.
73    pub fn builtin() -> Self {
74        const BUILTIN: &[(&str, &str)] = &[
75            ("filesystem", "modelcontextprotocol/servers"),
76            ("github", "modelcontextprotocol/servers"),
77            ("gitlab", "modelcontextprotocol/servers"),
78            ("git", "modelcontextprotocol/servers"),
79            ("memory", "modelcontextprotocol/servers"),
80            ("fetch", "modelcontextprotocol/servers"),
81            ("puppeteer", "modelcontextprotocol/servers"),
82            ("sequential-thinking", "modelcontextprotocol/servers"),
83            ("everything", "modelcontextprotocol/servers"),
84            ("brave-search", "modelcontextprotocol/servers"),
85            ("google-maps", "modelcontextprotocol/servers"),
86            ("slack", "modelcontextprotocol/servers"),
87            ("sqlite", "modelcontextprotocol/servers"),
88            ("postgres", "modelcontextprotocol/servers"),
89            ("time", "modelcontextprotocol/servers"),
90        ];
91        let entries = BUILTIN
92            .iter()
93            .map(|(name, source)| {
94                (
95                    (*name).to_string(),
96                    RegistryEntry {
97                        name: (*name).to_string(),
98                        source: (*source).to_string(),
99                    },
100                )
101            })
102            .collect();
103        Self { entries }
104    }
105
106    /// Number of entries. Convenience for callers that want a one-line
107    /// "registry has N known-good entries" message.
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    /// `true` if the registry has zero entries.
113    pub fn is_empty(&self) -> bool {
114        self.entries.is_empty()
115    }
116
117    /// Look up an exact name match.
118    pub fn get(&self, name: &str) -> Option<&RegistryEntry> {
119        self.entries.get(name)
120    }
121
122    /// Find the closest entry to `name` by Levenshtein distance, returning
123    /// `Some((entry, distance))` only when the distance is in `1..=max`
124    /// (zero would be an exact match — not a typosquat).
125    pub fn closest(&self, name: &str, max: usize) -> Option<(&RegistryEntry, usize)> {
126        let mut best: Option<(&RegistryEntry, usize)> = None;
127        for entry in self.entries.values() {
128            let d = levenshtein(name, &entry.name);
129            if d == 0 || d > max {
130                continue;
131            }
132            best = match best {
133                Some((_, bd)) if bd <= d => best,
134                _ => Some((entry, d)),
135            };
136        }
137        best
138    }
139
140    /// Fetch the registry from a JSON endpoint serving a
141    /// `[{"name":..., "source":...}, ...]` array.
142    ///
143    /// # Errors
144    ///
145    /// - [`crate::Error::Http`] on network / TLS / non-2xx response.
146    /// - [`crate::Error::Json`] on payload shape mismatch.
147    pub async fn fetch(url: &str) -> Result<Self> {
148        let client = reqwest::Client::builder()
149            .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
150            .user_agent(USER_AGENT)
151            .build()?;
152        let resp = client.get(url).send().await?;
153        if !resp.status().is_success() {
154            return Err(Error::Sanitize(format!(
155                "registry fetch {url} returned HTTP {}",
156                resp.status()
157            )));
158        }
159        let entries: Vec<RegistryEntry> = resp.json().await?;
160        Ok(Self::from_entries(entries))
161    }
162
163    /// Build a registry from an arbitrary entries list. Useful for tests
164    /// and for parsing locally-managed allowlists.
165    pub fn from_entries(entries: Vec<RegistryEntry>) -> Self {
166        let entries = entries.into_iter().map(|e| (e.name.clone(), e)).collect();
167        Self { entries }
168    }
169
170    /// Path of the local cache file under [`Paths::home`].
171    pub fn cache_path(paths: &Paths) -> PathBuf {
172        paths.home.join("registry.json")
173    }
174
175    /// Load the cached registry if [`Self::cache_path`] exists, else
176    /// `Ok(None)`.
177    ///
178    /// # Errors
179    ///
180    /// - [`crate::Error::Io`] on read failure.
181    /// - [`crate::Error::Json`] on parse failure.
182    pub fn load_cached(paths: &Paths) -> Result<Option<Self>> {
183        let path = Self::cache_path(paths);
184        if !path.exists() {
185            return Ok(None);
186        }
187        let body = fs::read_to_string(&path)?;
188        let reg: Self = serde_json::from_str(&body)?;
189        Ok(Some(reg))
190    }
191
192    /// Persist `self` to [`Self::cache_path`].
193    ///
194    /// # Errors
195    ///
196    /// - [`crate::Error::Io`] on dir / file write failure.
197    /// - [`crate::Error::Json`] on serialization failure.
198    pub fn save_cached(&self, paths: &Paths) -> Result<PathBuf> {
199        fs::create_dir_all(&paths.home)?;
200        let path = Self::cache_path(paths);
201        let body = serde_json::to_string_pretty(self)?;
202        fs::write(&path, body)?;
203        Ok(path)
204    }
205
206    /// Resolve a registry through the source chain documented at the
207    /// module level: cache → builtin. Network fetch is **not** triggered
208    /// here — callers that want a fresh pull should call
209    /// [`Self::fetch`] explicitly and then [`Self::save_cached`].
210    pub fn load_or_builtin(paths: &Paths) -> Self {
211        Self::load_cached(paths)
212            .ok()
213            .flatten()
214            .unwrap_or_else(Self::builtin)
215    }
216}
217
218/// Plain Levenshtein distance over byte-strings. Lowercases both inputs so
219/// `"GitHub"` and `"github"` collide at distance 0. Names are ASCII in
220/// practice, so byte-wise distance equals codepoint distance.
221fn levenshtein(a: &str, b: &str) -> usize {
222    let a = a.to_lowercase();
223    let b = b.to_lowercase();
224    let av: Vec<u8> = a.bytes().collect();
225    let bv: Vec<u8> = b.bytes().collect();
226    let (n, m) = (av.len(), bv.len());
227    if n == 0 {
228        return m;
229    }
230    if m == 0 {
231        return n;
232    }
233    let mut prev: Vec<usize> = (0..=m).collect();
234    let mut curr: Vec<usize> = vec![0; m + 1];
235    for i in 1..=n {
236        curr[0] = i;
237        for j in 1..=m {
238            let cost = usize::from(av[i - 1] != bv[j - 1]);
239            curr[j] = (curr[j - 1] + 1) // insertion
240                .min(prev[j] + 1) // deletion
241                .min(prev[j - 1] + cost); // substitution
242        }
243        std::mem::swap(&mut prev, &mut curr);
244    }
245    prev[m]
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
253        Paths {
254            home: tmp.path().to_path_buf(),
255            user_home: tmp.path().to_path_buf(),
256        }
257    }
258
259    #[test]
260    fn builtin_is_non_empty_and_contains_filesystem() {
261        let r = Registry::builtin();
262        assert!(!r.is_empty());
263        assert!(r.get("filesystem").is_some());
264    }
265
266    #[test]
267    fn exact_lookup_is_case_sensitive() {
268        // Demo-level: name matching is byte-exact. `closest` lowercases,
269        // but `get` does not — callers should canonicalise names before
270        // exact lookup.
271        let r = Registry::builtin();
272        assert!(r.get("filesystem").is_some());
273        assert!(r.get("FileSystem").is_none());
274    }
275
276    #[test]
277    fn closest_finds_typosquats_within_distance() {
278        let r = Registry::builtin();
279        // "filesystme" → "filesystem" (transposition + missing letter,
280        // distance 2).
281        let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
282        assert_eq!(entry.name, "filesystem");
283        assert!(d > 0 && d <= 2);
284    }
285
286    #[test]
287    fn closest_skips_exact_matches() {
288        let r = Registry::builtin();
289        assert!(r.closest("filesystem", 2).is_none());
290    }
291
292    #[test]
293    fn closest_returns_none_when_outside_distance() {
294        let r = Registry::builtin();
295        assert!(r.closest("completely-unrelated-name", 2).is_none());
296    }
297
298    #[test]
299    fn cache_round_trip() {
300        let tmp = tempfile::tempdir().unwrap();
301        let paths = paths_for(&tmp);
302        let original = Registry::builtin();
303        let written = original.save_cached(&paths).unwrap();
304        assert!(written.exists());
305        let loaded = Registry::load_cached(&paths).unwrap().unwrap();
306        assert_eq!(loaded.len(), original.len());
307    }
308
309    #[test]
310    fn load_or_builtin_uses_cache_when_present() {
311        let tmp = tempfile::tempdir().unwrap();
312        let paths = paths_for(&tmp);
313        // Save a 1-entry custom registry; load_or_builtin must surface it
314        // rather than the builtin fallback.
315        let custom = Registry::from_entries(vec![RegistryEntry {
316            name: "my-internal-server".into(),
317            source: "private allowlist".into(),
318        }]);
319        custom.save_cached(&paths).unwrap();
320        let resolved = Registry::load_or_builtin(&paths);
321        assert_eq!(resolved.len(), 1);
322        assert!(resolved.get("my-internal-server").is_some());
323        assert!(resolved.get("filesystem").is_none());
324    }
325
326    #[test]
327    fn load_or_builtin_falls_back_when_no_cache() {
328        let tmp = tempfile::tempdir().unwrap();
329        let paths = paths_for(&tmp);
330        let resolved = Registry::load_or_builtin(&paths);
331        assert!(resolved.get("filesystem").is_some());
332    }
333
334    #[test]
335    fn levenshtein_known_pairs() {
336        assert_eq!(levenshtein("kitten", "sitting"), 3);
337        assert_eq!(levenshtein("a", "a"), 0);
338        assert_eq!(levenshtein("", "abc"), 3);
339        assert_eq!(levenshtein("abc", ""), 3);
340        assert_eq!(levenshtein("GitHub", "github"), 0); // case-insensitive
341    }
342}