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    /// any platform-specific MCP config file (e.g. `.mcp.json`,
55    /// `~/.claude.json`).
56    pub name: String,
57    /// Free-form provenance string (URL, "builtin", filename, etc.). Echoed
58    /// back through [`crate::scan::unknown::UnknownVerdict::reason`] so the
59    /// user can audit why a name was approved.
60    pub source: String,
61}
62
63/// In-memory known-good registry. Cheap to clone (entries live in a
64/// `HashMap`, keys are short ASCII names).
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct Registry {
67    /// All entries keyed by `name` for O(1) exact-match lookup.
68    pub entries: HashMap<String, RegistryEntry>,
69}
70
71impl Registry {
72    /// Hardcoded fallback list — well-known `modelcontextprotocol/servers`
73    /// entries. Always succeeds, never touches the network or filesystem.
74    pub fn builtin() -> Self {
75        const BUILTIN: &[(&str, &str)] = &[
76            ("filesystem", "modelcontextprotocol/servers"),
77            ("github", "modelcontextprotocol/servers"),
78            ("gitlab", "modelcontextprotocol/servers"),
79            ("git", "modelcontextprotocol/servers"),
80            ("memory", "modelcontextprotocol/servers"),
81            ("fetch", "modelcontextprotocol/servers"),
82            ("puppeteer", "modelcontextprotocol/servers"),
83            ("sequential-thinking", "modelcontextprotocol/servers"),
84            ("everything", "modelcontextprotocol/servers"),
85            ("brave-search", "modelcontextprotocol/servers"),
86            ("google-maps", "modelcontextprotocol/servers"),
87            ("slack", "modelcontextprotocol/servers"),
88            ("sqlite", "modelcontextprotocol/servers"),
89            ("postgres", "modelcontextprotocol/servers"),
90            ("time", "modelcontextprotocol/servers"),
91        ];
92        let entries = BUILTIN
93            .iter()
94            .map(|(name, source)| {
95                (
96                    (*name).to_string(),
97                    RegistryEntry {
98                        name: (*name).to_string(),
99                        source: (*source).to_string(),
100                    },
101                )
102            })
103            .collect();
104        Self { entries }
105    }
106
107    /// Number of entries. Convenience for callers that want a one-line
108    /// "registry has N known-good entries" message.
109    pub fn len(&self) -> usize {
110        self.entries.len()
111    }
112
113    /// `true` if the registry has zero entries.
114    pub fn is_empty(&self) -> bool {
115        self.entries.is_empty()
116    }
117
118    /// Look up an exact name match.
119    pub fn get(&self, name: &str) -> Option<&RegistryEntry> {
120        self.entries.get(name)
121    }
122
123    /// Find the closest entry to `name` by Levenshtein distance, returning
124    /// `Some((entry, distance))` only when the distance is in `1..=max`
125    /// (zero would be an exact match — not a typosquat).
126    pub fn closest(&self, name: &str, max: usize) -> Option<(&RegistryEntry, usize)> {
127        let mut best: Option<(&RegistryEntry, usize)> = None;
128        for entry in self.entries.values() {
129            let d = levenshtein(name, &entry.name);
130            if d == 0 || d > max {
131                continue;
132            }
133            best = match best {
134                Some((_, bd)) if bd <= d => best,
135                _ => Some((entry, d)),
136            };
137        }
138        best
139    }
140
141    /// Fetch the registry from a JSON endpoint serving a
142    /// `[{"name":..., "source":...}, ...]` array.
143    ///
144    /// # Errors
145    ///
146    /// - [`crate::Error::Http`] on network / TLS / non-2xx response.
147    /// - [`crate::Error::Json`] on payload shape mismatch.
148    pub async fn fetch(url: &str) -> Result<Self> {
149        let client = reqwest::Client::builder()
150            .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
151            .user_agent(USER_AGENT)
152            .build()?;
153        let resp = client.get(url).send().await?;
154        if !resp.status().is_success() {
155            return Err(Error::Sanitize(format!(
156                "registry fetch {url} returned HTTP {}",
157                resp.status()
158            )));
159        }
160        let entries: Vec<RegistryEntry> = resp.json().await?;
161        Ok(Self::from_entries(entries))
162    }
163
164    /// Build a registry from an arbitrary entries list. Useful for tests
165    /// and for parsing locally-managed allowlists.
166    pub fn from_entries(entries: Vec<RegistryEntry>) -> Self {
167        let entries = entries.into_iter().map(|e| (e.name.clone(), e)).collect();
168        Self { entries }
169    }
170
171    /// Path of the local cache file under [`Paths::home`].
172    pub fn cache_path(paths: &Paths) -> PathBuf {
173        paths.home.join("registry.json")
174    }
175
176    /// Load the cached registry if [`Self::cache_path`] exists, else
177    /// `Ok(None)`.
178    ///
179    /// # Errors
180    ///
181    /// - [`crate::Error::Io`] on read failure.
182    /// - [`crate::Error::Json`] on parse failure.
183    pub fn load_cached(paths: &Paths) -> Result<Option<Self>> {
184        let path = Self::cache_path(paths);
185        if !path.exists() {
186            return Ok(None);
187        }
188        let body = fs::read_to_string(&path)?;
189        let reg: Self = serde_json::from_str(&body)?;
190        Ok(Some(reg))
191    }
192
193    /// Persist `self` to [`Self::cache_path`].
194    ///
195    /// # Errors
196    ///
197    /// - [`crate::Error::Io`] on dir / file write failure.
198    /// - [`crate::Error::Json`] on serialization failure.
199    pub fn save_cached(&self, paths: &Paths) -> Result<PathBuf> {
200        fs::create_dir_all(&paths.home)?;
201        let path = Self::cache_path(paths);
202        let body = serde_json::to_string_pretty(self)?;
203        fs::write(&path, body)?;
204        Ok(path)
205    }
206
207    /// Resolve a registry through the source chain documented at the
208    /// module level: cache → builtin. Network fetch is **not** triggered
209    /// here — callers that want a fresh pull should call
210    /// [`Self::fetch`] explicitly and then [`Self::save_cached`].
211    pub fn load_or_builtin(paths: &Paths) -> Self {
212        Self::load_cached(paths)
213            .ok()
214            .flatten()
215            .unwrap_or_else(Self::builtin)
216    }
217
218    // ── per-user local allowlist ──────────────────────────────────────────
219
220    /// Path of the per-user local allowlist under [`Paths::home`].
221    pub fn local_path(paths: &Paths) -> PathBuf {
222        paths.home.join("registry-local.json")
223    }
224
225    /// Load the local allowlist.  File absent → empty [`Registry`]; parse
226    /// error → propagate [`crate::Error`].
227    ///
228    /// # Errors
229    ///
230    /// - [`crate::Error::Io`] on read failure (file exists but unreadable).
231    /// - [`crate::Error::Json`] on parse failure.
232    pub fn load_local(paths: &Paths) -> Result<Self> {
233        let path = Self::local_path(paths);
234        if !path.exists() {
235            return Ok(Self::default());
236        }
237        let body = fs::read_to_string(&path)?;
238        let reg: Self = serde_json::from_str(&body)?;
239        Ok(reg)
240    }
241
242    /// Persist `self` to [`Self::local_path`].
243    ///
244    /// # Errors
245    ///
246    /// - [`crate::Error::Io`] on dir / file write failure.
247    /// - [`crate::Error::Json`] on serialization failure.
248    pub fn save_local(&self, paths: &Paths) -> Result<()> {
249        fs::create_dir_all(&paths.home)?;
250        let path = Self::local_path(paths);
251        let body = serde_json::to_string_pretty(self)?;
252        fs::write(&path, body)?;
253        Ok(())
254    }
255
256    /// Add (or upsert) a single entry to the local allowlist.
257    ///
258    /// `source` defaults to `"local"` when `None`.
259    ///
260    /// # Errors
261    ///
262    /// Propagates I/O or JSON errors from [`Self::load_local`] /
263    /// [`Self::save_local`].
264    pub fn add_local(paths: &Paths, name: &str, source: Option<&str>) -> Result<()> {
265        let mut reg = Self::load_local(paths)?;
266        let entry = RegistryEntry {
267            name: name.to_string(),
268            source: source.unwrap_or("local").to_string(),
269        };
270        reg.entries.insert(name.to_string(), entry);
271        reg.save_local(paths)
272    }
273
274    /// Remove a single entry from the local allowlist.  No-op when the
275    /// entry is absent.
276    ///
277    /// # Errors
278    ///
279    /// Propagates I/O or JSON errors from [`Self::load_local`] /
280    /// [`Self::save_local`].
281    pub fn remove_local(paths: &Paths, name: &str) -> Result<()> {
282        let mut reg = Self::load_local(paths)?;
283        reg.entries.remove(name);
284        reg.save_local(paths)
285    }
286
287    /// Resolve the three-source chain: `builtin < fetched-cache < local`.
288    ///
289    /// When the same name appears in multiple sources, the highest-priority
290    /// source wins (local > fetched > builtin).
291    ///
292    /// # Errors
293    ///
294    /// Propagates I/O or JSON errors from loading the fetched cache or the
295    /// local allowlist.  The builtin layer never fails.
296    pub fn load_resolved(paths: &Paths) -> Result<Self> {
297        // Start from the builtin base.
298        let mut merged = Self::builtin();
299        // Layer 2: fetched cache (overwrites builtin on name collision).
300        if let Some(fetched) = Self::load_cached(paths)? {
301            for (name, entry) in fetched.entries {
302                merged.entries.insert(name, entry);
303            }
304        }
305        // Layer 3: local allowlist (highest priority).
306        let local = Self::load_local(paths)?;
307        for (name, entry) in local.entries {
308            merged.entries.insert(name, entry);
309        }
310        Ok(merged)
311    }
312}
313
314/// Plain Levenshtein distance over byte-strings. Lowercases both inputs so
315/// `"GitHub"` and `"github"` collide at distance 0. Names are ASCII in
316/// practice, so byte-wise distance equals codepoint distance.
317fn levenshtein(a: &str, b: &str) -> usize {
318    let a = a.to_lowercase();
319    let b = b.to_lowercase();
320    let av: Vec<u8> = a.bytes().collect();
321    let bv: Vec<u8> = b.bytes().collect();
322    let (n, m) = (av.len(), bv.len());
323    if n == 0 {
324        return m;
325    }
326    if m == 0 {
327        return n;
328    }
329    let mut prev: Vec<usize> = (0..=m).collect();
330    let mut curr: Vec<usize> = vec![0; m + 1];
331    for i in 1..=n {
332        curr[0] = i;
333        for j in 1..=m {
334            let cost = usize::from(av[i - 1] != bv[j - 1]);
335            curr[j] = (curr[j - 1] + 1) // insertion
336                .min(prev[j] + 1) // deletion
337                .min(prev[j - 1] + cost); // substitution
338        }
339        std::mem::swap(&mut prev, &mut curr);
340    }
341    prev[m]
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
349        Paths {
350            home: tmp.path().to_path_buf(),
351            user_home: tmp.path().to_path_buf(),
352        }
353    }
354
355    #[test]
356    fn builtin_is_non_empty_and_contains_filesystem() {
357        let r = Registry::builtin();
358        assert!(!r.is_empty());
359        assert!(r.get("filesystem").is_some());
360    }
361
362    #[test]
363    fn exact_lookup_is_case_sensitive() {
364        // Demo-level: name matching is byte-exact. `closest` lowercases,
365        // but `get` does not — callers should canonicalise names before
366        // exact lookup.
367        let r = Registry::builtin();
368        assert!(r.get("filesystem").is_some());
369        assert!(r.get("FileSystem").is_none());
370    }
371
372    #[test]
373    fn closest_finds_typosquats_within_distance() {
374        let r = Registry::builtin();
375        // "filesystme" → "filesystem" (transposition + missing letter,
376        // distance 2).
377        let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
378        assert_eq!(entry.name, "filesystem");
379        assert!(d > 0 && d <= 2);
380    }
381
382    #[test]
383    fn closest_skips_exact_matches() {
384        let r = Registry::builtin();
385        assert!(r.closest("filesystem", 2).is_none());
386    }
387
388    #[test]
389    fn closest_returns_none_when_outside_distance() {
390        let r = Registry::builtin();
391        assert!(r.closest("completely-unrelated-name", 2).is_none());
392    }
393
394    #[test]
395    fn cache_round_trip() {
396        let tmp = tempfile::tempdir().unwrap();
397        let paths = paths_for(&tmp);
398        let original = Registry::builtin();
399        let written = original.save_cached(&paths).unwrap();
400        assert!(written.exists());
401        let loaded = Registry::load_cached(&paths).unwrap().unwrap();
402        assert_eq!(loaded.len(), original.len());
403    }
404
405    #[test]
406    fn load_or_builtin_uses_cache_when_present() {
407        let tmp = tempfile::tempdir().unwrap();
408        let paths = paths_for(&tmp);
409        // Save a 1-entry custom registry; load_or_builtin must surface it
410        // rather than the builtin fallback.
411        let custom = Registry::from_entries(vec![RegistryEntry {
412            name: "my-internal-server".into(),
413            source: "private allowlist".into(),
414        }]);
415        custom.save_cached(&paths).unwrap();
416        let resolved = Registry::load_or_builtin(&paths);
417        assert_eq!(resolved.len(), 1);
418        assert!(resolved.get("my-internal-server").is_some());
419        assert!(resolved.get("filesystem").is_none());
420    }
421
422    #[test]
423    fn load_or_builtin_falls_back_when_no_cache() {
424        let tmp = tempfile::tempdir().unwrap();
425        let paths = paths_for(&tmp);
426        let resolved = Registry::load_or_builtin(&paths);
427        assert!(resolved.get("filesystem").is_some());
428    }
429
430    #[test]
431    fn levenshtein_known_pairs() {
432        assert_eq!(levenshtein("kitten", "sitting"), 3);
433        assert_eq!(levenshtein("a", "a"), 0);
434        assert_eq!(levenshtein("", "abc"), 3);
435        assert_eq!(levenshtein("abc", ""), 3);
436        assert_eq!(levenshtein("GitHub", "github"), 0); // case-insensitive
437    }
438
439    // ── local allowlist tests ─────────────────────────────────────────────
440
441    #[test]
442    fn load_resolved_empty_local_falls_back_to_builtin_only() {
443        let tmp = tempfile::tempdir().unwrap();
444        let paths = paths_for(&tmp);
445        // No cache, no local file → must equal builtin.
446        let resolved = Registry::load_resolved(&paths).unwrap();
447        let builtin = Registry::builtin();
448        assert_eq!(resolved.len(), builtin.len());
449        assert!(resolved.get("filesystem").is_some());
450    }
451
452    #[test]
453    fn load_resolved_local_overrides_builtin() {
454        let tmp = tempfile::tempdir().unwrap();
455        let paths = paths_for(&tmp);
456        // Add a local entry with the same name as a builtin but different source.
457        Registry::add_local(&paths, "filesystem", Some("my-fork")).unwrap();
458        let resolved = Registry::load_resolved(&paths).unwrap();
459        let entry = resolved.get("filesystem").unwrap();
460        assert_eq!(entry.source, "my-fork");
461    }
462
463    #[test]
464    fn load_resolved_local_overrides_fetched() {
465        let tmp = tempfile::tempdir().unwrap();
466        let paths = paths_for(&tmp);
467        // Simulate a fetched cache entry.
468        let fetched = Registry::from_entries(vec![RegistryEntry {
469            name: "github".into(),
470            source: "fetched-registry".into(),
471        }]);
472        fetched.save_cached(&paths).unwrap();
473        // Local entry for the same name wins.
474        Registry::add_local(&paths, "github", Some("personal-fork")).unwrap();
475        let resolved = Registry::load_resolved(&paths).unwrap();
476        let entry = resolved.get("github").unwrap();
477        assert_eq!(entry.source, "personal-fork");
478    }
479
480    #[test]
481    fn add_local_persists_across_reload() {
482        let tmp = tempfile::tempdir().unwrap();
483        let paths = paths_for(&tmp);
484        Registry::add_local(&paths, "my-custom-server", None).unwrap();
485        let loaded = Registry::load_local(&paths).unwrap();
486        let entry = loaded.get("my-custom-server").unwrap();
487        assert_eq!(entry.source, "local");
488    }
489
490    #[test]
491    fn remove_local_removes_entry() {
492        let tmp = tempfile::tempdir().unwrap();
493        let paths = paths_for(&tmp);
494        Registry::add_local(&paths, "to-remove", Some("test")).unwrap();
495        assert!(
496            Registry::load_local(&paths)
497                .unwrap()
498                .get("to-remove")
499                .is_some()
500        );
501        Registry::remove_local(&paths, "to-remove").unwrap();
502        assert!(
503            Registry::load_local(&paths)
504                .unwrap()
505                .get("to-remove")
506                .is_none()
507        );
508    }
509}