1use 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
23const NAVIGATION_TOOLS: &[&str] = &["navigate_page", "new_page"];
25
26const EVALUATE_SCRIPT_TOOL: &str = "evaluate_script";
31
32pub struct DomainAllowlistGuard {
39 allow: HashSet<String>,
40 allow_evaluate_script: bool,
41}
42
43impl DomainAllowlistGuard {
44 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 allow_evaluate_script: false,
59 }
60 }
61
62 pub fn allow_evaluate_script(mut self, allow: bool) -> Self {
65 self.allow_evaluate_script = allow;
66 self
67 }
68
69 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
78fn normalize_host(s: &str) -> String {
81 let s = s.trim().to_lowercase();
82 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
91pub(crate) fn url_host(url: &str) -> Option<String> {
95 let u = url.trim();
96 if matches!(u, "back" | "forward" | "reload") {
98 return None;
99 }
100 let after_scheme = match u.split_once("://") {
101 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, None => {
112 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, }
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); let host = host.split(':').next().unwrap_or(host); 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 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 None => GuardAction::Allow,
164 },
165 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 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 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 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 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 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 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 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 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}