1use serde::{Deserialize, Serialize};
22use std::collections::BTreeSet;
23
24pub(crate) const DEFAULT_ALLOWLIST_TOML: &str = include_str!("../data/network_allowlist.toml");
30
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[derive(Debug, Clone, Default, Deserialize, Serialize)]
34pub struct NetworkAllowlist {
35 #[serde(default)]
37 meta: Option<AllowlistMeta>,
38
39 #[serde(default)]
41 ai_providers: AiProviderCategories,
42
43 #[serde(default)]
45 search: SearchCategories,
46
47 #[serde(default, rename = "web_crawl")]
49 web_crawl: Vec<AllowlistEntry>,
50
51 #[serde(default, rename = "mcp_servers")]
53 mcp_servers: Vec<AllowlistEntry>,
54
55 #[serde(default)]
57 package_registries: Vec<AllowlistEntry>,
58
59 #[serde(default)]
61 code_hosting: Vec<AllowlistEntry>,
62
63 #[serde(default)]
65 auth: Vec<AllowlistEntry>,
66
67 #[serde(default, rename = "dev_infra")]
69 dev_infra: Vec<AllowlistEntry>,
70
71 #[serde(default, rename = "os_updates")]
73 os_updates: Vec<AllowlistEntry>,
74}
75
76#[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#[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#[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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
120pub struct AllowlistEntry {
121 #[serde(default)]
123 name: Option<String>,
124 #[serde(default)]
126 domain: Option<String>,
127 #[serde(default)]
129 path: Option<String>,
130 #[serde(default)]
132 protocol: Option<String>,
133 #[serde(default)]
135 notes: Option<String>,
136 #[serde(default)]
139 verify: bool,
140}
141
142#[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 pub(crate) fn load_default() -> Self {
165 toml::from_str(DEFAULT_ALLOWLIST_TOML).unwrap_or_default()
166 }
167
168 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 fn all_allow_domains_set(&self) -> BTreeSet<String> {
203 self.all_allow_domains().into_iter().collect()
204 }
205
206 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 && 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 pub fn unverified_entries(&self) -> Vec<&AllowlistEntry> {
264 self.iter_entries().filter(|e| e.verify).collect()
265 }
266
267 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 fn entry_count(&self) -> usize {
287 self.iter_entries().count()
288 }
289
290 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 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}