use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use crate::agent::guardrail::{GuardAction, Guardrail};
use crate::error::Error;
use crate::llm::types::ToolCall;
const NAVIGATION_TOOLS: &[&str] = &["navigate_page", "new_page"];
const EVALUATE_SCRIPT_TOOL: &str = "evaluate_script";
pub struct DomainAllowlistGuard {
allow: HashSet<String>,
allow_evaluate_script: bool,
}
impl DomainAllowlistGuard {
pub fn new(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
let allow = hosts
.into_iter()
.map(|h| normalize_host(&h.into()))
.collect();
Self {
allow,
allow_evaluate_script: false,
}
}
pub fn allow_evaluate_script(mut self, allow: bool) -> Self {
self.allow_evaluate_script = allow;
self
}
pub fn host_allowed(&self, host: &str) -> bool {
let host = host.to_lowercase();
self.allow
.iter()
.any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}")))
}
}
fn normalize_host(s: &str) -> String {
let s = s.trim().to_lowercase();
let s = s.rsplit("://").next().unwrap_or(&s);
let s = s.split('/').next().unwrap_or(s);
let s = s.strip_prefix("*.").unwrap_or(s);
let s = s.split(':').next().unwrap_or(s);
s.to_string()
}
pub(crate) fn url_host(url: &str) -> Option<String> {
let u = url.trim();
if matches!(u, "back" | "forward" | "reload") {
return None;
}
let after_scheme = match u.split_once("://") {
Some((scheme, rest))
if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") =>
{
rest
}
Some(_) => return None, None => {
let head = u.split(['/', '?', '#']).next().unwrap_or(u);
match head.split_once(':') {
Some((_, tail)) if tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) => {
return None;
}
_ => u, }
}
};
let host = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(after_scheme);
let host = host.split('@').next_back().unwrap_or(host); let host = host.split(':').next().unwrap_or(host); if host.is_empty() {
None
} else {
Some(host.to_lowercase())
}
}
impl Guardrail for DomainAllowlistGuard {
fn name(&self) -> &str {
"browser-domain-allowlist"
}
fn pre_tool(
&self,
call: &ToolCall,
) -> Pin<Box<dyn Future<Output = Result<GuardAction, Error>> + Send + '_>> {
let action = if call.name == EVALUATE_SCRIPT_TOOL && !self.allow_evaluate_script {
GuardAction::deny(
"evaluate_script denied: an arbitrary script body can exfiltrate page \
data to any host, bypassing the domain allowlist. Enable it explicitly \
(allow_evaluate_script) only when operating on trusted pages.",
)
} else if NAVIGATION_TOOLS.contains(&call.name.as_str()) {
match call.input.get("url").and_then(|v| v.as_str()) {
Some(url) => match url_host(url) {
Some(host) if self.host_allowed(&host) => GuardAction::Allow,
Some(host) => GuardAction::deny(format!(
"navigation to '{host}' denied: host not on the browser allowlist"
)),
None => GuardAction::Allow,
},
None => GuardAction::Allow,
}
} else {
GuardAction::Allow
};
Box::pin(async move { Ok(action) })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn nav(url: &str) -> ToolCall {
ToolCall {
id: "c1".into(),
name: "navigate_page".into(),
input: serde_json::json!({ "url": url }),
}
}
async fn decide(g: &DomainAllowlistGuard, call: &ToolCall) -> GuardAction {
g.pre_tool(call).await.expect("guard ok")
}
#[test]
fn url_host_extraction() {
assert_eq!(
url_host("https://example.com/a/b?x=1"),
Some("example.com".into())
);
assert_eq!(
url_host("http://Docs.Example.com:8080/"),
Some("docs.example.com".into())
);
assert_eq!(
url_host("https://user:pw@evil.com/x"),
Some("evil.com".into())
);
assert_eq!(url_host("example.com/path"), Some("example.com".into()));
assert_eq!(url_host("about:blank"), None);
assert_eq!(url_host("javascript:alert(1)"), None);
assert_eq!(url_host("back"), None);
}
#[test]
fn url_host_scheme_is_case_insensitive() {
assert_eq!(url_host("HTTP://evil.com/steal"), Some("evil.com".into()));
assert_eq!(url_host("Https://evil.com"), Some("evil.com".into()));
assert_eq!(url_host("hTtP://Evil.COM/x"), Some("evil.com".into()));
assert_eq!(url_host("ABOUT:blank"), None);
assert_eq!(url_host("JavaScript:alert(1)"), None);
}
#[tokio::test]
async fn mixed_case_scheme_is_gated_not_bypassed() {
let g = DomainAllowlistGuard::new(["example.com"]);
match decide(&g, &nav("HTTP://evil.com/steal")).await {
GuardAction::Deny { reason } => {
assert!(reason.contains("evil.com"), "reason: {reason}")
}
other => panic!("uppercase-scheme off-allowlist nav must be denied, got {other:?}"),
}
assert_eq!(
decide(&g, &nav("HTTPS://example.com/login")).await,
GuardAction::Allow
);
}
#[test]
fn subdomain_matches_allowlisted_apex() {
let g = DomainAllowlistGuard::new(["example.com"]);
assert!(g.host_allowed("example.com"));
assert!(g.host_allowed("docs.example.com"));
assert!(g.host_allowed("a.b.example.com"));
assert!(!g.host_allowed("notexample.com"));
assert!(!g.host_allowed("example.com.evil.com"));
}
#[tokio::test]
async fn allows_navigation_to_allowlisted_host() {
let g = DomainAllowlistGuard::new(["example.com"]);
assert_eq!(
decide(&g, &nav("https://example.com/login")).await,
GuardAction::Allow
);
assert_eq!(
decide(&g, &nav("https://api.example.com/v1")).await,
GuardAction::Allow
);
}
#[tokio::test]
async fn denies_navigation_off_allowlist() {
let g = DomainAllowlistGuard::new(["example.com"]);
let action = decide(&g, &nav("https://evil.com/steal")).await;
match action {
GuardAction::Deny { reason } => {
assert!(reason.contains("evil.com"), "reason: {reason}");
assert!(reason.contains("allowlist"), "reason: {reason}");
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[tokio::test]
async fn empty_allowlist_denies_all_navigation() {
let g = DomainAllowlistGuard::new(Vec::<String>::new());
assert!(matches!(
decide(&g, &nav("https://example.com")).await,
GuardAction::Deny { .. }
));
}
#[tokio::test]
async fn non_navigation_tools_pass_through() {
let g = DomainAllowlistGuard::new(["example.com"]);
let click = ToolCall {
id: "c2".into(),
name: "click".into(),
input: serde_json::json!({ "uid": "1_2" }),
};
assert_eq!(decide(&g, &click).await, GuardAction::Allow);
}
#[tokio::test]
async fn evaluate_script_denied_by_default_and_opt_in_allows() {
let eval = ToolCall {
id: "e1".into(),
name: "evaluate_script".into(),
input: serde_json::json!({ "function": "() => document.cookie" }),
};
let g = DomainAllowlistGuard::new(["example.com"]);
assert!(
decide(&g, &eval).await.is_denied(),
"evaluate_script must be denied by default"
);
let g_opt = DomainAllowlistGuard::new(["example.com"]).allow_evaluate_script(true);
assert_eq!(decide(&g_opt, &eval).await, GuardAction::Allow);
}
#[tokio::test]
async fn back_forward_and_nonweb_schemes_allowed() {
let g = DomainAllowlistGuard::new(["example.com"]);
assert_eq!(decide(&g, &nav("back")).await, GuardAction::Allow);
assert_eq!(decide(&g, &nav("about:blank")).await, GuardAction::Allow);
let no_url = ToolCall {
id: "c3".into(),
name: "navigate_page".into(),
input: serde_json::json!({}),
};
assert_eq!(decide(&g, &no_url).await, GuardAction::Allow);
}
#[tokio::test]
async fn new_page_is_also_gated() {
let g = DomainAllowlistGuard::new(["example.com"]);
let new_evil = ToolCall {
id: "c4".into(),
name: "new_page".into(),
input: serde_json::json!({ "url": "https://evil.com" }),
};
assert!(matches!(
decide(&g, &new_evil).await,
GuardAction::Deny { .. }
));
}
}