Skip to main content

microclaw_engine/internal/tool_runtime/
website_policy.rs

1//! robots.txt-aware policy gate for `web_fetch`.
2//!
3//! Before each outbound fetch, consult the host's /robots.txt and honor
4//! Disallow entries for a sensible User-agent. Results cached per host.
5//!
6//! Port of hermes-agent's `tools/website_policy.py`, simplified:
7//! - User-agent matching: we treat "MicroClaw" and "*" as equivalent
8//! - Disallow + Allow rules; longest-prefix wins for conflicts
9//! - Crawl-Delay read and surfaced via `CrawlHint::delay`
10//! - Pages that 404 / 5xx / fail network → policy = allow (fail-open)
11
12use std::collections::HashMap;
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16use reqwest::Url;
17
18const CACHE_TTL: Duration = Duration::from_secs(30 * 60);
19const MAX_ROBOTS_BYTES: usize = 500_000;
20
21#[derive(Debug, Clone, Default)]
22struct RobotsRules {
23    disallow: Vec<String>,
24    allow: Vec<String>,
25    crawl_delay: Option<f64>,
26}
27
28#[derive(Debug, Clone)]
29struct CachedRobots {
30    fetched_at: Instant,
31    rules: RobotsRules,
32}
33
34#[derive(Debug, Clone)]
35pub struct CrawlHint {
36    pub allowed: bool,
37    pub reason: Option<String>,
38    pub crawl_delay_secs: Option<f64>,
39}
40
41fn cache() -> &'static Mutex<HashMap<String, CachedRobots>> {
42    use std::sync::OnceLock;
43    static CACHE: OnceLock<Mutex<HashMap<String, CachedRobots>>> = OnceLock::new();
44    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
45}
46
47fn cache_key(scheme: &str, host: &str, port: Option<u16>) -> String {
48    match port {
49        Some(p) => format!("{scheme}://{host}:{p}"),
50        None => format!("{scheme}://{host}"),
51    }
52}
53
54fn parse_robots_txt(text: &str, our_agent: &str) -> RobotsRules {
55    // Parse in simple linear mode: track "current User-agent" while reading;
56    // collect the union of rules for groups matching our_agent or '*'.
57    let mut our_rules = RobotsRules::default();
58    let mut star_rules = RobotsRules::default();
59    let mut current: Option<Vec<String>> = None; // ua set of current group
60    let our_agent_lower = our_agent.to_ascii_lowercase();
61
62    for raw_line in text.lines() {
63        let line = raw_line.split('#').next().unwrap_or("").trim();
64        if line.is_empty() {
65            continue;
66        }
67        let Some((key, value)) = line.split_once(':') else {
68            continue;
69        };
70        let key = key.trim().to_ascii_lowercase();
71        let value = value.trim().to_string();
72        match key.as_str() {
73            "user-agent" => {
74                if current.as_ref().is_some_and(|_| true) {
75                    // Previous group ended; decide next
76                }
77                current = Some(vec![value.to_ascii_lowercase()]);
78            }
79            "disallow" | "allow" | "crawl-delay" => {
80                let Some(uas) = current.as_ref() else {
81                    continue;
82                };
83                let matches_ours = uas.iter().any(|u| u == &our_agent_lower);
84                let matches_star = uas.iter().any(|u| u == "*");
85                let targets: Vec<&mut RobotsRules> = if matches_ours {
86                    vec![&mut our_rules]
87                } else if matches_star {
88                    vec![&mut star_rules]
89                } else {
90                    continue;
91                };
92                for rules in targets {
93                    match key.as_str() {
94                        "disallow" if !value.is_empty() => {
95                            rules.disallow.push(value.clone());
96                        }
97                        "allow" if !value.is_empty() => {
98                            rules.allow.push(value.clone());
99                        }
100                        "crawl-delay" => {
101                            if let Ok(n) = value.parse::<f64>() {
102                                rules.crawl_delay = Some(n);
103                            }
104                        }
105                        _ => {}
106                    }
107                }
108            }
109            _ => {}
110        }
111    }
112
113    // Our-agent rules take precedence; fall back to '*'.
114    if our_rules.disallow.is_empty()
115        && our_rules.allow.is_empty()
116        && our_rules.crawl_delay.is_none()
117    {
118        our_rules = star_rules;
119    }
120    our_rules
121}
122
123/// Return `CrawlHint::allowed = false` if the path is Disallow'd by the
124/// cached rules; otherwise allowed. Longest-prefix match wins between
125/// Allow and Disallow.
126fn evaluate_path(rules: &RobotsRules, path: &str) -> CrawlHint {
127    let mut best_allow = 0usize;
128    let mut best_disallow = 0usize;
129    for rule in &rules.allow {
130        if path.starts_with(rule) && rule.len() > best_allow {
131            best_allow = rule.len();
132        }
133    }
134    for rule in &rules.disallow {
135        if path.starts_with(rule) && rule.len() > best_disallow {
136            best_disallow = rule.len();
137        }
138    }
139    if best_disallow > best_allow {
140        CrawlHint {
141            allowed: false,
142            reason: Some(format!(
143                "disallowed by robots.txt (rule len {best_disallow})"
144            )),
145            crawl_delay_secs: rules.crawl_delay,
146        }
147    } else {
148        CrawlHint {
149            allowed: true,
150            reason: None,
151            crawl_delay_secs: rules.crawl_delay,
152        }
153    }
154}
155
156/// Fetch (or reuse) robots.txt for the host of `url` and return a hint
157/// about whether the agent should proceed. Fail-open: network / 4xx /
158/// 5xx → allowed.
159pub async fn consult_robots(client: &reqwest::Client, url: &Url, user_agent: &str) -> CrawlHint {
160    let Some(host) = url.host_str() else {
161        return CrawlHint {
162            allowed: true,
163            reason: None,
164            crawl_delay_secs: None,
165        };
166    };
167    let scheme = url.scheme();
168    let port = url.port();
169    let key = cache_key(scheme, host, port);
170    let now = Instant::now();
171    if let Ok(guard) = cache().lock() {
172        if let Some(entry) = guard.get(&key) {
173            if now.duration_since(entry.fetched_at) < CACHE_TTL {
174                return evaluate_path(&entry.rules, url.path());
175            }
176        }
177    }
178
179    let robots_url = {
180        let mut u = url.clone();
181        u.set_path("/robots.txt");
182        u.set_query(None);
183        u.set_fragment(None);
184        u
185    };
186    let body = match client.get(robots_url).send().await {
187        Ok(r) if r.status().is_success() => r.text().await.unwrap_or_default(),
188        _ => String::new(),
189    };
190    let truncated = if body.len() > MAX_ROBOTS_BYTES {
191        body[..MAX_ROBOTS_BYTES].to_string()
192    } else {
193        body
194    };
195    let rules = parse_robots_txt(&truncated, user_agent);
196    if let Ok(mut guard) = cache().lock() {
197        guard.insert(
198            key,
199            CachedRobots {
200                fetched_at: now,
201                rules: rules.clone(),
202            },
203        );
204    }
205    evaluate_path(&rules, url.path())
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn empty_robots_allows_all() {
214        let rules = parse_robots_txt("", "microclaw");
215        let hint = evaluate_path(&rules, "/anything");
216        assert!(hint.allowed);
217    }
218
219    #[test]
220    fn disallow_blocks_matching_prefix() {
221        let txt = "User-agent: *\nDisallow: /private/\n";
222        let rules = parse_robots_txt(txt, "microclaw");
223        assert!(!evaluate_path(&rules, "/private/doc").allowed);
224        assert!(evaluate_path(&rules, "/public/doc").allowed);
225    }
226
227    #[test]
228    fn allow_overrides_disallow_when_longer() {
229        let txt = "User-agent: *\nDisallow: /private/\nAllow: /private/public/\n";
230        let rules = parse_robots_txt(txt, "microclaw");
231        assert!(!evaluate_path(&rules, "/private/doc").allowed);
232        assert!(evaluate_path(&rules, "/private/public/doc").allowed);
233    }
234
235    #[test]
236    fn ua_specific_rules_override_star() {
237        let txt = "User-agent: *\nDisallow: /\n\nUser-agent: microclaw\nDisallow: /secret/\n";
238        let rules = parse_robots_txt(txt, "microclaw");
239        // microclaw-specific rules replace '*' fallback, so only /secret/ blocked.
240        assert!(!evaluate_path(&rules, "/secret/x").allowed);
241        assert!(evaluate_path(&rules, "/open").allowed);
242    }
243
244    #[test]
245    fn crawl_delay_parsed() {
246        let txt = "User-agent: *\nCrawl-Delay: 5\n";
247        let rules = parse_robots_txt(txt, "microclaw");
248        assert_eq!(rules.crawl_delay, Some(5.0));
249    }
250
251    #[test]
252    fn comments_stripped() {
253        let txt = "User-agent: * # applies to all\nDisallow: /admin # internal\n";
254        let rules = parse_robots_txt(txt, "microclaw");
255        assert!(!evaluate_path(&rules, "/admin/panel").allowed);
256    }
257}