Skip to main content

ravenclaws/
web_policy.rs

1//! # Web Access Policy (domain-level)
2//!
3//! Category-based allowlist/blocklist policy for web-facing tools (`web_fetch`,
4//! `web_search`, `browser`). This complements [`crate::policy::PolicyEngine`],
5//! which is resource-level (shell/path/network allow-lists for *tool execution*);
6//! this module operates at the *domain* level, classifying destinations into
7//! named categories and enforcing per-category allow/block/permission rules.
8//!
9//! ## Architecture
10//!
11//! ```text
12//! URL / domain
13//!   │
14//!   ▼
15//! WebAccessPolicy::is_allowed()
16//!   ├── policy disabled        → Allow("policy_disabled")
17//!   ├── matches blocklist      → Deny("blocked by <category>")
18//!   ├── category needs consent → Deny("permission required (<category>)")
19//!   └── otherwise              → Allow("<category>")
20//! ```
21//!
22//! The module also provides a [`RateLimiter`] for per-category rate limiting and
23//! [`extract_domain`] to normalize a URL or search query into a bare domain.
24//!
25//! ## Usage
26//!
27//! ```rust,no_run
28//! use ravenclaws::web_policy::{WebAccessPolicy, extract_domain};
29//!
30//! let policy = WebAccessPolicy::default();
31//! let domain = extract_domain("https://docs.rs/ravenclaws");
32//! let (allowed, reason) = policy.is_allowed(&domain);
33//! assert!(allowed);
34//! ```
35
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38
39/// A named category of web destinations with its own allow/block lists.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct WebCategory {
42    /// Category name (e.g. "news", "code_repos", "social_media")
43    pub name: String,
44    /// Human-readable description
45    pub description: String,
46    /// Domains classified into this category (substring match, case-insensitive)
47    pub allowlist: Vec<String>,
48    /// Domains explicitly blocked within this category (takes precedence)
49    pub blocklist: Vec<String>,
50    /// If true, access requires explicit user permission even if allowlisted
51    pub require_permission: bool,
52}
53
54/// Domain-level web access policy.
55///
56/// # Stability
57/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[non_exhaustive]
60pub struct WebAccessPolicy {
61    /// Master switch — when false, all web access is allowed
62    pub enabled: bool,
63    /// Policy mode: "permissive" (allow unless blocked) or "strict" (deny unless allowlisted)
64    pub mode: String,
65    /// Per-category rate limit (requests per minute)
66    pub rate_limit_per_minute: u32,
67    /// Maximum concurrent fetches
68    pub max_concurrent_fetches: u32,
69    /// Maximum result size in bytes
70    pub max_result_size_bytes: usize,
71    /// Ordered category definitions
72    pub categories: Vec<WebCategory>,
73}
74
75impl WebAccessPolicy {
76    /// A policy that is disabled — all web access is allowed.
77    ///
78    /// This is the safe default for existing deployments; users opt into
79    /// domain filtering by setting `enabled = true` in their configuration.
80    pub fn disabled() -> Self {
81        Self {
82            enabled: false,
83            ..Self::default()
84        }
85    }
86
87    /// Whether the policy operates in strict mode (deny unless explicitly allowlisted).
88    pub fn is_strict(&self) -> bool {
89        self.mode.eq_ignore_ascii_case("strict")
90    }
91
92    /// Classify a domain into a category. Returns the first category whose
93    /// allowlist or blocklist matches, or the final (uncategorized) category.
94    pub fn classify(&self, domain: &str) -> &WebCategory {
95        let domain_lower = domain.to_lowercase();
96        for cat in &self.categories {
97            if cat
98                .blocklist
99                .iter()
100                .any(|d| domain_lower.contains(d.as_str()))
101            {
102                return cat;
103            }
104            if cat
105                .allowlist
106                .iter()
107                .any(|d| domain_lower.contains(d.as_str()))
108            {
109                return cat;
110            }
111        }
112        // Fall back to the last category (conventionally "uncategorized").
113        self.categories
114            .last()
115            .expect("WebAccessPolicy categories must not be empty")
116    }
117
118    /// Check whether a domain is allowed by policy.
119    ///
120    /// Returns `(allowed, reason)` where `reason` is the category name on allow
121    /// (or "policy_disabled") and a human-readable denial reason on deny.
122    pub fn is_allowed(&self, domain: &str) -> (bool, String) {
123        if !self.enabled {
124            return (true, "policy_disabled".to_string());
125        }
126        let domain_lower = domain.to_lowercase();
127        let cat = self.classify(domain);
128
129        // Blocklist always wins.
130        if cat
131            .blocklist
132            .iter()
133            .any(|d| domain_lower.contains(d.as_str()))
134        {
135            return (false, format!("blocked by {} category", cat.name));
136        }
137
138        // Strict mode: only explicit allowlist entries pass.
139        if self.is_strict()
140            && !cat
141                .allowlist
142                .iter()
143                .any(|d| domain_lower.contains(d.as_str()))
144        {
145            return (false, format!("not allowlisted (category: {})", cat.name));
146        }
147
148        // Permission-gated categories require consent.
149        if cat.require_permission {
150            return (
151                false,
152                format!("permission required (category: {})", cat.name),
153            );
154        }
155
156        (true, cat.name.clone())
157    }
158}
159
160impl Default for WebAccessPolicy {
161    fn default() -> Self {
162        Self {
163            enabled: true,
164            mode: "permissive".to_string(),
165            rate_limit_per_minute: 30,
166            max_concurrent_fetches: 5,
167            max_result_size_bytes: 1_048_576,
168            categories: default_categories(),
169        }
170    }
171}
172
173/// The default category set: news, search, code repositories, documentation,
174/// social media (blocked by default), and an uncategorized catch-all.
175pub fn default_categories() -> Vec<WebCategory> {
176    vec![
177        WebCategory {
178            name: "news".to_string(),
179            description: "News sites and media outlets".to_string(),
180            allowlist: vec![
181                "nrk.no".to_string(),
182                "vg.no".to_string(),
183                "bbc.com".to_string(),
184                "cnn.com".to_string(),
185                "reuters.com".to_string(),
186                "apnews.com".to_string(),
187                "theguardian.com".to_string(),
188                "nytimes.com".to_string(),
189                "dw.com".to_string(),
190            ],
191            blocklist: vec![],
192            require_permission: false,
193        },
194        WebCategory {
195            name: "search".to_string(),
196            description: "Search engines".to_string(),
197            allowlist: vec![
198                "google.com".to_string(),
199                "bing.com".to_string(),
200                "duckduckgo.com".to_string(),
201                "search.brave.com".to_string(),
202                "kagi.com".to_string(),
203            ],
204            blocklist: vec![],
205            require_permission: false,
206        },
207        WebCategory {
208            name: "code_repos".to_string(),
209            description: "Code repositories".to_string(),
210            allowlist: vec![
211                "github.com".to_string(),
212                "gitlab.com".to_string(),
213                "bitbucket.org".to_string(),
214                "codeberg.org".to_string(),
215                "gitea.com".to_string(),
216            ],
217            blocklist: vec![],
218            require_permission: false,
219        },
220        WebCategory {
221            name: "documentation".to_string(),
222            description: "Technical docs and references".to_string(),
223            allowlist: vec![
224                "docs.rs".to_string(),
225                "crates.io".to_string(),
226                "pypi.org".to_string(),
227                "npmjs.com".to_string(),
228                "developer.mozilla.org".to_string(),
229                "w3.org".to_string(),
230                "stackoverflow.com".to_string(),
231                "wikipedia.org".to_string(),
232                "arxiv.org".to_string(),
233                "kubernetes.io".to_string(),
234                "helm.sh".to_string(),
235            ],
236            blocklist: vec![],
237            require_permission: false,
238        },
239        WebCategory {
240            name: "social_media".to_string(),
241            description: "Social media platforms".to_string(),
242            allowlist: vec![],
243            blocklist: vec![
244                "facebook.com".to_string(),
245                "instagram.com".to_string(),
246                "twitter.com".to_string(),
247                "tiktok.com".to_string(),
248                "snapchat.com".to_string(),
249                "reddit.com".to_string(),
250            ],
251            require_permission: true,
252        },
253        WebCategory {
254            name: "uncategorized".to_string(),
255            description: "All other domains".to_string(),
256            allowlist: vec![],
257            blocklist: vec![],
258            require_permission: true,
259        },
260    ]
261}
262
263/// Extract a bare domain from a URL or search query string.
264///
265/// Strips surrounding quotes and the URL scheme, then returns everything up to
266/// the first `/`. For plain search queries (no scheme), returns the first token.
267pub fn extract_domain(param_str: &str) -> String {
268    let s = param_str.trim().trim_matches('"');
269    if let Some(start) = s.find("://") {
270        let after = &s[start + 3..];
271        let end = after.find('/').unwrap_or(after.len());
272        return after[..end].to_string();
273    }
274    let end = s.find('/').unwrap_or(s.len());
275    s[..end].to_string()
276}
277
278/// A sliding-window (per-minute) rate limiter keyed by an arbitrary string
279/// (typically a domain or category name).
280#[allow(dead_code)]
281#[derive(Debug, Clone)]
282pub struct RateLimiter {
283    buckets: HashMap<String, (std::time::Instant, u32)>,
284    max_per_minute: u32,
285}
286
287#[allow(dead_code)]
288impl RateLimiter {
289    /// Create a new rate limiter allowing `max_per_minute` requests per key.
290    pub fn new(max_per_minute: u32) -> Self {
291        Self {
292            buckets: HashMap::new(),
293            max_per_minute,
294        }
295    }
296
297    /// Check whether a request for `key` is within the rate limit.
298    /// Returns `true` if the request is allowed.
299    pub fn check(&mut self, key: &str) -> bool {
300        let now = std::time::Instant::now();
301        let entry = self.buckets.entry(key.to_string()).or_insert((now, 0));
302        if now.duration_since(entry.0).as_secs() > 60 {
303            *entry = (now, 1);
304            return true;
305        }
306        if entry.1 >= self.max_per_minute {
307            return false;
308        }
309        entry.1 += 1;
310        true
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_default_policy_enabled_permissive() {
320        let p = WebAccessPolicy::default();
321        assert!(p.enabled);
322        assert!(!p.is_strict());
323        assert!(!p.categories.is_empty());
324    }
325
326    #[test]
327    fn test_allowlisted_domain_passes() {
328        let p = WebAccessPolicy::default();
329        let (allowed, reason) = p.is_allowed("github.com");
330        assert!(allowed);
331        assert_eq!(reason, "code_repos");
332    }
333
334    #[test]
335    fn test_blocklisted_domain_denied() {
336        let p = WebAccessPolicy::default();
337        let (allowed, reason) = p.is_allowed("facebook.com");
338        assert!(!allowed);
339        assert!(reason.contains("blocked"));
340    }
341
342    #[test]
343    fn test_uncategorized_requires_permission() {
344        let p = WebAccessPolicy::default();
345        let (allowed, reason) = p.is_allowed("some-random-domain.example");
346        assert!(!allowed);
347        assert!(reason.contains("permission required"));
348    }
349
350    #[test]
351    fn test_disabled_policy_allows_all() {
352        let p = WebAccessPolicy {
353            enabled: false,
354            ..WebAccessPolicy::default()
355        };
356        let (allowed, reason) = p.is_allowed("facebook.com");
357        assert!(allowed);
358        assert_eq!(reason, "policy_disabled");
359    }
360
361    #[test]
362    fn test_strict_mode_denies_unallowlisted() {
363        let p = WebAccessPolicy {
364            mode: "strict".to_string(),
365            ..WebAccessPolicy::default()
366        };
367        assert!(p.is_strict());
368        // github.com is allowlisted (code_repos) → allowed
369        assert!(p.is_allowed("github.com").0);
370        // arbitrary domain is not allowlisted → denied in strict mode
371        let (allowed, reason) = p.is_allowed("not-in-any-list.example");
372        assert!(!allowed);
373        assert!(reason.contains("not allowlisted"));
374    }
375
376    #[test]
377    fn test_classify_case_insensitive() {
378        let p = WebAccessPolicy::default();
379        assert_eq!(p.classify("GitHub.COM").name, "code_repos");
380        assert_eq!(p.classify("github.com").name, "code_repos");
381    }
382
383    #[test]
384    fn test_extract_domain_from_url() {
385        assert_eq!(extract_domain("https://docs.rs/ravenclaws"), "docs.rs");
386        assert_eq!(extract_domain("http://github.com/egkristi"), "github.com");
387        assert_eq!(
388            extract_domain("\"https://example.com/path\""),
389            "example.com"
390        );
391    }
392
393    #[test]
394    fn test_extract_domain_from_query() {
395        assert_eq!(extract_domain("rust documentation"), "rust documentation");
396        assert_eq!(extract_domain("github.com"), "github.com");
397    }
398
399    #[test]
400    fn test_rate_limiter_allows_within_limit() {
401        let mut rl = RateLimiter::new(2);
402        assert!(rl.check("docs.rs"));
403        assert!(rl.check("docs.rs"));
404        assert!(!rl.check("docs.rs")); // third request within the minute is denied
405    }
406
407    #[test]
408    fn test_rate_limiter_independent_keys() {
409        let mut rl = RateLimiter::new(1);
410        assert!(rl.check("a.example"));
411        assert!(rl.check("b.example")); // different key, unaffected
412        assert!(!rl.check("a.example"));
413    }
414}