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    // ── per-user local allowlist ──────────────────────────────────────────
218
219    /// Path of the per-user local allowlist under [`Paths::home`].
220    pub fn local_path(paths: &Paths) -> PathBuf {
221        paths.home.join("registry-local.json")
222    }
223
224    /// Load the local allowlist.  File absent → empty [`Registry`]; parse
225    /// error → propagate [`crate::Error`].
226    ///
227    /// # Errors
228    ///
229    /// - [`crate::Error::Io`] on read failure (file exists but unreadable).
230    /// - [`crate::Error::Json`] on parse failure.
231    pub fn load_local(paths: &Paths) -> Result<Self> {
232        let path = Self::local_path(paths);
233        if !path.exists() {
234            return Ok(Self::default());
235        }
236        let body = fs::read_to_string(&path)?;
237        let reg: Self = serde_json::from_str(&body)?;
238        Ok(reg)
239    }
240
241    /// Persist `self` to [`Self::local_path`].
242    ///
243    /// # Errors
244    ///
245    /// - [`crate::Error::Io`] on dir / file write failure.
246    /// - [`crate::Error::Json`] on serialization failure.
247    pub fn save_local(&self, paths: &Paths) -> Result<()> {
248        fs::create_dir_all(&paths.home)?;
249        let path = Self::local_path(paths);
250        let body = serde_json::to_string_pretty(self)?;
251        fs::write(&path, body)?;
252        Ok(())
253    }
254
255    /// Add (or upsert) a single entry to the local allowlist.
256    ///
257    /// `source` defaults to `"local"` when `None`.
258    ///
259    /// # Errors
260    ///
261    /// Propagates I/O or JSON errors from [`Self::load_local`] /
262    /// [`Self::save_local`].
263    pub fn add_local(paths: &Paths, name: &str, source: Option<&str>) -> Result<()> {
264        let mut reg = Self::load_local(paths)?;
265        let entry = RegistryEntry {
266            name: name.to_string(),
267            source: source.unwrap_or("local").to_string(),
268        };
269        reg.entries.insert(name.to_string(), entry);
270        reg.save_local(paths)
271    }
272
273    /// Remove a single entry from the local allowlist.  No-op when the
274    /// entry is absent.
275    ///
276    /// # Errors
277    ///
278    /// Propagates I/O or JSON errors from [`Self::load_local`] /
279    /// [`Self::save_local`].
280    pub fn remove_local(paths: &Paths, name: &str) -> Result<()> {
281        let mut reg = Self::load_local(paths)?;
282        reg.entries.remove(name);
283        reg.save_local(paths)
284    }
285
286    /// Resolve the three-source chain: `builtin < fetched-cache < local`.
287    ///
288    /// When the same name appears in multiple sources, the highest-priority
289    /// source wins (local > fetched > builtin).
290    ///
291    /// # Errors
292    ///
293    /// Propagates I/O or JSON errors from loading the fetched cache or the
294    /// local allowlist.  The builtin layer never fails.
295    pub fn load_resolved(paths: &Paths) -> Result<Self> {
296        // Start from the builtin base.
297        let mut merged = Self::builtin();
298        // Layer 2: fetched cache (overwrites builtin on name collision).
299        if let Some(fetched) = Self::load_cached(paths)? {
300            for (name, entry) in fetched.entries {
301                merged.entries.insert(name, entry);
302            }
303        }
304        // Layer 3: local allowlist (highest priority).
305        let local = Self::load_local(paths)?;
306        for (name, entry) in local.entries {
307            merged.entries.insert(name, entry);
308        }
309        Ok(merged)
310    }
311}
312
313/// Plain Levenshtein distance over byte-strings. Lowercases both inputs so
314/// `"GitHub"` and `"github"` collide at distance 0. Names are ASCII in
315/// practice, so byte-wise distance equals codepoint distance.
316fn levenshtein(a: &str, b: &str) -> usize {
317    let a = a.to_lowercase();
318    let b = b.to_lowercase();
319    let av: Vec<u8> = a.bytes().collect();
320    let bv: Vec<u8> = b.bytes().collect();
321    let (n, m) = (av.len(), bv.len());
322    if n == 0 {
323        return m;
324    }
325    if m == 0 {
326        return n;
327    }
328    let mut prev: Vec<usize> = (0..=m).collect();
329    let mut curr: Vec<usize> = vec![0; m + 1];
330    for i in 1..=n {
331        curr[0] = i;
332        for j in 1..=m {
333            let cost = usize::from(av[i - 1] != bv[j - 1]);
334            curr[j] = (curr[j - 1] + 1) // insertion
335                .min(prev[j] + 1) // deletion
336                .min(prev[j - 1] + cost); // substitution
337        }
338        std::mem::swap(&mut prev, &mut curr);
339    }
340    prev[m]
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
348        Paths {
349            home: tmp.path().to_path_buf(),
350            user_home: tmp.path().to_path_buf(),
351        }
352    }
353
354    #[test]
355    fn builtin_is_non_empty_and_contains_filesystem() {
356        let r = Registry::builtin();
357        assert!(!r.is_empty());
358        assert!(r.get("filesystem").is_some());
359    }
360
361    #[test]
362    fn exact_lookup_is_case_sensitive() {
363        // Demo-level: name matching is byte-exact. `closest` lowercases,
364        // but `get` does not — callers should canonicalise names before
365        // exact lookup.
366        let r = Registry::builtin();
367        assert!(r.get("filesystem").is_some());
368        assert!(r.get("FileSystem").is_none());
369    }
370
371    #[test]
372    fn closest_finds_typosquats_within_distance() {
373        let r = Registry::builtin();
374        // "filesystme" → "filesystem" (transposition + missing letter,
375        // distance 2).
376        let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
377        assert_eq!(entry.name, "filesystem");
378        assert!(d > 0 && d <= 2);
379    }
380
381    #[test]
382    fn closest_skips_exact_matches() {
383        let r = Registry::builtin();
384        assert!(r.closest("filesystem", 2).is_none());
385    }
386
387    #[test]
388    fn closest_returns_none_when_outside_distance() {
389        let r = Registry::builtin();
390        assert!(r.closest("completely-unrelated-name", 2).is_none());
391    }
392
393    #[test]
394    fn cache_round_trip() {
395        let tmp = tempfile::tempdir().unwrap();
396        let paths = paths_for(&tmp);
397        let original = Registry::builtin();
398        let written = original.save_cached(&paths).unwrap();
399        assert!(written.exists());
400        let loaded = Registry::load_cached(&paths).unwrap().unwrap();
401        assert_eq!(loaded.len(), original.len());
402    }
403
404    #[test]
405    fn load_or_builtin_uses_cache_when_present() {
406        let tmp = tempfile::tempdir().unwrap();
407        let paths = paths_for(&tmp);
408        // Save a 1-entry custom registry; load_or_builtin must surface it
409        // rather than the builtin fallback.
410        let custom = Registry::from_entries(vec![RegistryEntry {
411            name: "my-internal-server".into(),
412            source: "private allowlist".into(),
413        }]);
414        custom.save_cached(&paths).unwrap();
415        let resolved = Registry::load_or_builtin(&paths);
416        assert_eq!(resolved.len(), 1);
417        assert!(resolved.get("my-internal-server").is_some());
418        assert!(resolved.get("filesystem").is_none());
419    }
420
421    #[test]
422    fn load_or_builtin_falls_back_when_no_cache() {
423        let tmp = tempfile::tempdir().unwrap();
424        let paths = paths_for(&tmp);
425        let resolved = Registry::load_or_builtin(&paths);
426        assert!(resolved.get("filesystem").is_some());
427    }
428
429    #[test]
430    fn levenshtein_known_pairs() {
431        assert_eq!(levenshtein("kitten", "sitting"), 3);
432        assert_eq!(levenshtein("a", "a"), 0);
433        assert_eq!(levenshtein("", "abc"), 3);
434        assert_eq!(levenshtein("abc", ""), 3);
435        assert_eq!(levenshtein("GitHub", "github"), 0); // case-insensitive
436    }
437
438    // ── local allowlist tests ─────────────────────────────────────────────
439
440    #[test]
441    fn load_resolved_empty_local_falls_back_to_builtin_only() {
442        let tmp = tempfile::tempdir().unwrap();
443        let paths = paths_for(&tmp);
444        // No cache, no local file → must equal builtin.
445        let resolved = Registry::load_resolved(&paths).unwrap();
446        let builtin = Registry::builtin();
447        assert_eq!(resolved.len(), builtin.len());
448        assert!(resolved.get("filesystem").is_some());
449    }
450
451    #[test]
452    fn load_resolved_local_overrides_builtin() {
453        let tmp = tempfile::tempdir().unwrap();
454        let paths = paths_for(&tmp);
455        // Add a local entry with the same name as a builtin but different source.
456        Registry::add_local(&paths, "filesystem", Some("my-fork")).unwrap();
457        let resolved = Registry::load_resolved(&paths).unwrap();
458        let entry = resolved.get("filesystem").unwrap();
459        assert_eq!(entry.source, "my-fork");
460    }
461
462    #[test]
463    fn load_resolved_local_overrides_fetched() {
464        let tmp = tempfile::tempdir().unwrap();
465        let paths = paths_for(&tmp);
466        // Simulate a fetched cache entry.
467        let fetched = Registry::from_entries(vec![RegistryEntry {
468            name: "github".into(),
469            source: "fetched-registry".into(),
470        }]);
471        fetched.save_cached(&paths).unwrap();
472        // Local entry for the same name wins.
473        Registry::add_local(&paths, "github", Some("personal-fork")).unwrap();
474        let resolved = Registry::load_resolved(&paths).unwrap();
475        let entry = resolved.get("github").unwrap();
476        assert_eq!(entry.source, "personal-fork");
477    }
478
479    #[test]
480    fn add_local_persists_across_reload() {
481        let tmp = tempfile::tempdir().unwrap();
482        let paths = paths_for(&tmp);
483        Registry::add_local(&paths, "my-custom-server", None).unwrap();
484        let loaded = Registry::load_local(&paths).unwrap();
485        let entry = loaded.get("my-custom-server").unwrap();
486        assert_eq!(entry.source, "local");
487    }
488
489    #[test]
490    fn remove_local_removes_entry() {
491        let tmp = tempfile::tempdir().unwrap();
492        let paths = paths_for(&tmp);
493        Registry::add_local(&paths, "to-remove", Some("test")).unwrap();
494        assert!(
495            Registry::load_local(&paths)
496                .unwrap()
497                .get("to-remove")
498                .is_some()
499        );
500        Registry::remove_local(&paths, "to-remove").unwrap();
501        assert!(
502            Registry::load_local(&paths)
503                .unwrap()
504                .get("to-remove")
505                .is_none()
506        );
507    }
508}