Skip to main content

vtcode_config/
network_allowlist.rs

1//! Curated network allowlist for VT Code's agent egress.
2//!
3//! The allowlist is loaded from a TOML file shipped with the crate (see
4//! `data/network_allowlist.toml`) and organized by category. Categories
5//! include AI provider endpoints, web & specialized search, web-crawl
6//! helpers (Jina, Defuddle, Firecrawl, etc.), MCP servers, package
7//! registries, code-hosting platforms, OAuth/identity, dev-infrastructure,
8//! and OS-update mirrors.
9//!
10//! The TOML is the source of truth; the Rust types in this module are
11//! derived from the on-disk shape so we can also expose the per-category
12//! lists (e.g., for diagnostics or for the agent prompt to describe
13//! "where you can fetch from"). The flat `all_allow_domains()` view
14//! collapses every category into one deduped `Vec<String>` and is what
15//! the `WebFetchConfig` defaults consume.
16//!
17//! The allowlist also supports a `verify = true` flag per entry. Entries
18//! with that flag are surfaced via [`NetworkAllowlist::unverified_entries`]
19//! so a startup hook can warn the operator before they're used.
20
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeSet;
23
24/// Embedded copy of the curated allowlist TOML.
25///
26/// Including the file at compile time means the allowlist is always
27/// available — even in WASM / sandbox environments where reading a
28/// runtime file path would be a problem.
29pub(crate) const DEFAULT_ALLOWLIST_TOML: &str = include_str!("../data/network_allowlist.toml");
30
31/// Top-level allowlist document.
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[derive(Debug, Clone, Default, Deserialize, Serialize)]
34pub struct NetworkAllowlist {
35    /// File-level metadata (`[meta]` block).
36    #[serde(default)]
37    meta: Option<AllowlistMeta>,
38
39    /// LLM inference endpoints (cloud-hosted).
40    #[serde(default)]
41    ai_providers: AiProviderCategories,
42
43    /// Search APIs (web + specialized/vertical).
44    #[serde(default)]
45    search: SearchCategories,
46
47    /// Full-page fetch & content extraction services.
48    #[serde(default, rename = "web_crawl")]
49    web_crawl: Vec<AllowlistEntry>,
50
51    /// MCP tool server endpoints.
52    #[serde(default, rename = "mcp_servers")]
53    mcp_servers: Vec<AllowlistEntry>,
54
55    /// Language package manager registries.
56    #[serde(default)]
57    package_registries: Vec<AllowlistEntry>,
58
59    /// Git platforms & raw content hosts.
60    #[serde(default)]
61    code_hosting: Vec<AllowlistEntry>,
62
63    /// OAuth & identity provider endpoints.
64    #[serde(default)]
65    auth: Vec<AllowlistEntry>,
66
67    /// Cloud infra, databases, observability.
68    #[serde(default, rename = "dev_infra")]
69    dev_infra: Vec<AllowlistEntry>,
70
71    /// System / OS package mirrors.
72    #[serde(default, rename = "os_updates")]
73    os_updates: Vec<AllowlistEntry>,
74}
75
76/// Allowlist file-level metadata.
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78#[derive(Debug, Clone, Default, Deserialize, Serialize)]
79pub struct AllowlistMeta {
80    #[serde(default)]
81    version: Option<String>,
82    #[serde(default)]
83    last_updated: Option<String>,
84    #[serde(default)]
85    maintainer: Option<String>,
86    #[serde(default)]
87    repo: Option<String>,
88    #[serde(default)]
89    description: Option<String>,
90}
91
92/// AI provider entries split into cloud vs. local/self-hosted.
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[derive(Debug, Clone, Default, Deserialize, Serialize)]
95pub struct AiProviderCategories {
96    #[serde(default)]
97    cloud: Vec<AllowlistEntry>,
98    #[serde(default)]
99    local: Vec<LocalAiProviderEntry>,
100}
101
102/// Search entries split into generic web vs. specialized/vertical.
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[derive(Debug, Clone, Default, Deserialize, Serialize)]
105pub struct SearchCategories {
106    #[serde(default)]
107    web: Vec<AllowlistEntry>,
108    #[serde(default)]
109    specialized: Vec<AllowlistEntry>,
110}
111
112/// A single allowlist row.
113///
114/// `domain` is the primary host key. Optional `path` narrows the exemption
115/// to URLs that begin with the given path (used for `github.com/login/oauth`).
116/// `verify = true` flags entries that should be re-confirmed before the
117/// agent relies on them.
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
120pub struct AllowlistEntry {
121    /// Human-readable label.
122    #[serde(default)]
123    name: Option<String>,
124    /// Apex domain (e.g. `github.com`). May be a wildcard like `*.auth0.com`.
125    #[serde(default)]
126    domain: Option<String>,
127    /// Optional path prefix that the URL must start with.
128    #[serde(default)]
129    path: Option<String>,
130    /// Network protocol; defaults to `https` when missing.
131    #[serde(default)]
132    protocol: Option<String>,
133    /// Free-form notes.
134    #[serde(default)]
135    notes: Option<String>,
136    /// When `true`, surface the entry via `unverified_entries` so callers
137    /// can warn or block until the operator confirms.
138    #[serde(default)]
139    verify: bool,
140}
141
142/// Local/self-hosted AI provider — identified by `host` + `port` rather
143/// than a public domain. The allowlist tracks these for diagnostics; the
144/// agent sandbox already permits `localhost` traffic via separate config.
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
147pub struct LocalAiProviderEntry {
148    #[serde(default)]
149    name: Option<String>,
150    #[serde(default)]
151    host: Option<String>,
152    #[serde(default)]
153    port: Option<u16>,
154    #[serde(default)]
155    protocol: Option<String>,
156    #[serde(default)]
157    notes: Option<String>,
158}
159
160impl NetworkAllowlist {
161    /// Parse the embedded TOML. Returns a default empty allowlist if the
162    /// parse fails so the agent can still start in degraded mode (a
163    /// warning is logged by the caller).
164    pub(crate) fn load_default() -> Self {
165        toml::from_str(DEFAULT_ALLOWLIST_TOML).unwrap_or_default()
166    }
167
168    /// Flat list of every domain string in the allowlist, including
169    /// wildcards. Local/self-hosted entries are excluded because they
170    /// have no public domain to match against.
171    ///
172    /// Order is preserved per category so the resulting list is
173    /// deterministic for tests and config dumps. Use a `BTreeSet` when
174    /// callers want stable dedup without ordering.
175    fn all_allow_domains(&self) -> Vec<String> {
176        let mut out: Vec<String> = Vec::new();
177        for entry in self
178            .ai_providers
179            .cloud
180            .iter()
181            .chain(self.search.web.iter())
182            .chain(self.search.specialized.iter())
183            .chain(self.web_crawl.iter())
184            .chain(self.mcp_servers.iter())
185            .chain(self.package_registries.iter())
186            .chain(self.code_hosting.iter())
187            .chain(self.auth.iter())
188            .chain(self.dev_infra.iter())
189            .chain(self.os_updates.iter())
190        {
191            if let Some(domain) = entry.domain.as_deref() {
192                let trimmed = domain.trim();
193                if !trimmed.is_empty() && !out.iter().any(|d| d == trimmed) {
194                    out.push(trimmed.to_string());
195                }
196            }
197        }
198        out
199    }
200
201    /// Deduplicated set form of `all_allow_domains()`.
202    fn all_allow_domains_set(&self) -> BTreeSet<String> {
203        self.all_allow_domains().into_iter().collect()
204    }
205
206    /// Domains from the categories that are appropriate for the
207    /// `web_fetch` tool's default allowlist.
208    ///
209    /// Excludes:
210    /// - `ai_providers.cloud` (LLM inference endpoints; require auth and
211    ///   return raw JSON, not web content)
212    /// - `auth` (OAuth/identity endpoints; never useful as a fetch target)
213    /// - `dev_infra` (backend services, observability endpoints; auth-
214    ///   required and not designed for public reading)
215    /// - `os_updates` (system package mirrors; not useful for an agent)
216    /// - `defuddle.md` from `web_crawl` (defuddle is a relay, not a fetch
217    ///   target; routing web_fetch to it would bypass the SSRF checks)
218    /// - `ai_providers.local` (no public domain)
219    ///
220    /// Includes the categories where the agent might want to read public
221    /// web content: search engines, specialized knowledge bases,
222    /// package registries, code-hosting platforms, MCP servers that
223    /// expose public web content, and web-crawl relays other than
224    /// defuddle.
225    pub(crate) fn web_fetch_relevant_domains(&self) -> Vec<String> {
226        let mut out: Vec<String> = Vec::new();
227        let mut push = |entry: &AllowlistEntry| {
228            if let Some(domain) = entry.domain.as_deref() {
229                let trimmed = domain.trim();
230                if !trimmed.is_empty()
231                    && !out.iter().any(|d: &String| d == trimmed)
232                    // defuddle.md is a relay, not a fetch target.
233                    && trimmed != "defuddle.md"
234                {
235                    out.push(trimmed.to_string());
236                }
237            }
238        };
239        for entry in &self.search.web {
240            push(entry);
241        }
242        for entry in &self.search.specialized {
243            push(entry);
244        }
245        for entry in &self.web_crawl {
246            push(entry);
247        }
248        for entry in &self.mcp_servers {
249            push(entry);
250        }
251        for entry in &self.package_registries {
252            push(entry);
253        }
254        for entry in &self.code_hosting {
255            push(entry);
256        }
257        out
258    }
259
260    /// Entries that the allowlist itself flags as `verify = true`. The
261    /// agent can refuse to use these (or surface a warning) until the
262    /// operator confirms.
263    pub fn unverified_entries(&self) -> Vec<&AllowlistEntry> {
264        self.iter_entries().filter(|e| e.verify).collect()
265    }
266
267    /// All entries across every category, in source-file order. Local
268    /// AI providers are excluded (no domain).
269    fn iter_entries(&self) -> impl Iterator<Item = &AllowlistEntry> {
270        self.ai_providers
271            .cloud
272            .iter()
273            .chain(self.search.web.iter())
274            .chain(self.search.specialized.iter())
275            .chain(self.web_crawl.iter())
276            .chain(self.mcp_servers.iter())
277            .chain(self.package_registries.iter())
278            .chain(self.code_hosting.iter())
279            .chain(self.auth.iter())
280            .chain(self.dev_infra.iter())
281            .chain(self.os_updates.iter())
282    }
283
284    /// Total number of allowlist entries across all categories (excluding
285    /// local AI providers).
286    fn entry_count(&self) -> usize {
287        self.iter_entries().count()
288    }
289
290    /// Pretty-print the allowlist as a one-line per category summary
291    /// (e.g. `"ai_providers.cloud: 27, search.web: 11, …"`). Useful in
292    /// startup logs so operators can confirm what shipped.
293    fn category_summary(&self) -> String {
294        let mut parts = Vec::new();
295        if !self.ai_providers.cloud.is_empty() {
296            parts.push(format!("ai_providers.cloud: {}", self.ai_providers.cloud.len()));
297        }
298        if !self.ai_providers.local.is_empty() {
299            parts.push(format!("ai_providers.local: {}", self.ai_providers.local.len()));
300        }
301        if !self.search.web.is_empty() {
302            parts.push(format!("search.web: {}", self.search.web.len()));
303        }
304        if !self.search.specialized.is_empty() {
305            parts.push(format!("search.specialized: {}", self.search.specialized.len()));
306        }
307        if !self.web_crawl.is_empty() {
308            parts.push(format!("web_crawl: {}", self.web_crawl.len()));
309        }
310        if !self.mcp_servers.is_empty() {
311            parts.push(format!("mcp_servers: {}", self.mcp_servers.len()));
312        }
313        if !self.package_registries.is_empty() {
314            parts.push(format!("package_registries: {}", self.package_registries.len()));
315        }
316        if !self.code_hosting.is_empty() {
317            parts.push(format!("code_hosting: {}", self.code_hosting.len()));
318        }
319        if !self.auth.is_empty() {
320            parts.push(format!("auth: {}", self.auth.len()));
321        }
322        if !self.dev_infra.is_empty() {
323            parts.push(format!("dev_infra: {}", self.dev_infra.len()));
324        }
325        if !self.os_updates.is_empty() {
326            parts.push(format!("os_updates: {}", self.os_updates.len()));
327        }
328        parts.join(", ")
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn load_default_parses_embedded_toml() {
338        let list = NetworkAllowlist::load_default();
339        // The shipped TOML has 103 entries; verify the broad shape
340        // before checking individual hosts.
341        assert!(list.entry_count() > 50, "expected many entries, got {}", list.entry_count());
342        assert!(list.entry_count() <= 200, "allowlist grew unexpectedly");
343    }
344
345    #[test]
346    fn load_default_includes_common_dev_hosts() {
347        let list = NetworkAllowlist::load_default();
348        let domains = list.all_allow_domains_set();
349        for host in [
350            "github.com",
351            "api.github.com",
352            "crates.io",
353            "registry.npmjs.org",
354            "pypi.org",
355            "defuddle.md",
356            "r.jina.ai",
357            "api.tavily.com",
358            "api.anthropic.com",
359        ] {
360            assert!(domains.contains(host), "default allowlist should include {host}; missing");
361        }
362    }
363
364    #[test]
365    fn load_default_preserves_wildcards() {
366        let list = NetworkAllowlist::load_default();
367        let domains = list.all_allow_domains_set();
368        for wildcard in ["*.auth0.com", "*.workers.dev", "*.vercel.app"] {
369            assert!(domains.contains(wildcard), "default allowlist should include wildcard {wildcard}");
370        }
371    }
372
373    #[test]
374    fn load_default_flags_unverified_entries() {
375        let list = NetworkAllowlist::load_default();
376        let unverified: Vec<&str> = list.unverified_entries().iter().filter_map(|e| e.name.as_deref()).collect();
377        assert!(
378            unverified.iter().any(|n| n.contains("MiMo")),
379            "expected MiMo to be flagged verify=true; got {unverified:?}"
380        );
381    }
382
383    #[test]
384    fn category_summary_lists_populated_categories() {
385        let list = NetworkAllowlist::load_default();
386        let summary = list.category_summary();
387        assert!(summary.contains("ai_providers.cloud"));
388        assert!(summary.contains("search.web"));
389        assert!(summary.contains("code_hosting"));
390    }
391}