Compare commits

...

2 Commits

Author SHA1 Message Date
jordanthejet
91a858a31a fix(tests): update config_schema test for default-enabled browser
The integration test asserted browser.enabled was false by default,
but it should now be true after the web tools default-enable change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:21:39 -05:00
jordanthejet
9067da5a16 feat(tools): default-enable web tools with unrestricted network access
Enable browser, http_request, web_fetch, and web_search tools by default
with wildcard domain access. Remove hardcoded private host and file://
blocking so the agent is fully capable out of the box.

Add NetworkAccessPolicy with config-based opt-in restrictions for power
users who want to limit local network or public internet access:

  [security]
  allow_local_network = false
  allow_public_internet = false

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:05:45 -05:00
7 changed files with 225 additions and 145 deletions

View File

@ -834,6 +834,10 @@ fn default_true() -> bool {
true
}
fn default_wildcard_domains() -> Vec<String> {
vec!["*".into()]
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
@ -958,10 +962,10 @@ impl Default for BrowserComputerUseConfig {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BrowserConfig {
/// Enable `browser_open` tool (opens URLs in the system browser without scraping)
#[serde(default)]
#[serde(default = "default_true")]
pub enabled: bool,
/// Allowed domains for `browser_open` (exact or subdomain match)
#[serde(default)]
#[serde(default = "default_wildcard_domains")]
pub allowed_domains: Vec<String>,
/// Browser session name (for agent-browser automation)
#[serde(default)]
@ -994,8 +998,8 @@ fn default_browser_webdriver_url() -> String {
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_domains: Vec::new(),
enabled: true,
allowed_domains: vec!["*".into()],
session_name: None,
backend: default_browser_backend(),
native_headless: default_true(),
@ -1014,10 +1018,10 @@ impl Default for BrowserConfig {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HttpRequestConfig {
/// Enable `http_request` tool for API interactions
#[serde(default)]
#[serde(default = "default_true")]
pub enabled: bool,
/// Allowed domains for HTTP requests (exact or subdomain match)
#[serde(default)]
#[serde(default = "default_wildcard_domains")]
pub allowed_domains: Vec<String>,
/// Maximum response size in bytes (default: 1MB, 0 = unlimited)
#[serde(default = "default_http_max_response_size")]
@ -1030,8 +1034,8 @@ pub struct HttpRequestConfig {
impl Default for HttpRequestConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_domains: vec![],
enabled: true,
allowed_domains: vec!["*".into()],
max_response_size: default_http_max_response_size(),
timeout_secs: default_http_timeout_secs(),
}
@ -1057,7 +1061,7 @@ fn default_http_timeout_secs() -> u64 {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebFetchConfig {
/// Enable `web_fetch` tool for fetching web page content
#[serde(default)]
#[serde(default = "default_true")]
pub enabled: bool,
/// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
#[serde(default)]
@ -1084,7 +1088,7 @@ fn default_web_fetch_timeout_secs() -> u64 {
impl Default for WebFetchConfig {
fn default() -> Self {
Self {
enabled: false,
enabled: true,
allowed_domains: vec!["*".into()],
blocked_domains: vec![],
max_response_size: default_web_fetch_max_response_size(),
@ -1099,7 +1103,7 @@ impl Default for WebFetchConfig {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebSearchConfig {
/// Enable `web_search_tool` for web searches
#[serde(default)]
#[serde(default = "default_true")]
pub enabled: bool,
/// Search provider: "duckduckgo" (free, no API key) or "brave" (requires API key)
#[serde(default = "default_web_search_provider")]
@ -1130,7 +1134,7 @@ fn default_web_search_timeout_secs() -> u64 {
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
enabled: false,
enabled: true,
provider: default_web_search_provider(),
brave_api_key: None,
max_results: default_web_search_max_results(),
@ -3260,6 +3264,16 @@ pub struct SecurityConfig {
/// Emergency-stop state machine configuration.
#[serde(default)]
pub estop: EstopConfig,
/// Allow tools to access localhost, private IPs (10.x, 192.168.x, etc.), and file:// URLs.
/// Set to false to block local/private network access and file:// URLs.
#[serde(default = "default_true")]
pub allow_local_network: bool,
/// Allow tools to access public internet hosts.
/// Set to false to block all public internet access from tools.
#[serde(default = "default_true")]
pub allow_public_internet: bool,
}
/// OTP validation strategy.
@ -4852,8 +4866,8 @@ mod tests {
let cfg = HttpRequestConfig::default();
assert_eq!(cfg.timeout_secs, 30);
assert_eq!(cfg.max_response_size, 1_000_000);
assert!(!cfg.enabled);
assert!(cfg.allowed_domains.is_empty());
assert!(cfg.enabled);
assert_eq!(cfg.allowed_domains, vec!["*"]);
}
#[test]
@ -6154,15 +6168,15 @@ default_temperature = 0.7
assert!(!c.composio.enabled);
assert!(c.composio.api_key.is_none());
assert!(c.secrets.encrypt);
assert!(!c.browser.enabled);
assert!(c.browser.allowed_domains.is_empty());
assert!(c.browser.enabled);
assert_eq!(c.browser.allowed_domains, vec!["*"]);
}
#[test]
async fn browser_config_default_disabled() {
async fn browser_config_default_enabled() {
let b = BrowserConfig::default();
assert!(!b.enabled);
assert!(b.allowed_domains.is_empty());
assert!(b.enabled);
assert_eq!(b.allowed_domains, vec!["*"]);
assert_eq!(b.backend, "agent_browser");
assert!(b.native_headless);
assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
@ -6227,8 +6241,8 @@ config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(!parsed.browser.enabled);
assert!(parsed.browser.allowed_domains.is_empty());
assert!(parsed.browser.enabled);
assert_eq!(parsed.browser.allowed_domains, vec!["*"]);
}
// ── Environment variable overrides (Docker support) ─────────

View File

@ -67,6 +67,7 @@ pub struct BrowserTool {
native_webdriver_url: String,
native_chrome_path: Option<String>,
computer_use: ComputerUseConfig,
net_policy: super::NetworkAccessPolicy,
#[cfg(feature = "browser-native")]
native_state: tokio::sync::Mutex<native_backend::NativeBrowserState>,
}
@ -211,6 +212,7 @@ impl BrowserTool {
"http://127.0.0.1:9515".into(),
None,
ComputerUseConfig::default(),
super::NetworkAccessPolicy::default(),
)
}
@ -224,6 +226,7 @@ impl BrowserTool {
native_webdriver_url: String,
native_chrome_path: Option<String>,
computer_use: ComputerUseConfig,
net_policy: super::NetworkAccessPolicy,
) -> Self {
Self {
security,
@ -234,6 +237,7 @@ impl BrowserTool {
native_webdriver_url,
native_chrome_path,
computer_use,
net_policy,
#[cfg(feature = "browser-native")]
native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()),
}
@ -406,14 +410,8 @@ impl BrowserTool {
anyhow::bail!("URL cannot be empty");
}
// Block file:// URLs — browser file access bypasses all SSRF and
// domain-allowlist controls and can exfiltrate arbitrary local files.
if url.starts_with("file://") {
anyhow::bail!("file:// URLs are not allowed in browser automation");
}
if !url.starts_with("https://") && !url.starts_with("http://") {
anyhow::bail!("Only http:// and https:// URLs are allowed");
if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("file://") {
anyhow::bail!("Only http://, https://, and file:// URLs are allowed");
}
if self.allowed_domains.is_empty() {
@ -423,10 +421,23 @@ impl BrowserTool {
);
}
// file:// URLs require local network access
if url.starts_with("file://") {
if !self.net_policy.allow_local_network {
anyhow::bail!("file:// URLs are blocked (security.allow_local_network = false)");
}
return Ok(());
}
let host = extract_host(url)?;
// Apply network access policy
if is_private_host(&host) {
anyhow::bail!("Blocked local/private host: {host}");
if !self.net_policy.allow_local_network {
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
}
} else if !self.net_policy.allow_public_internet {
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
}
if !host_matches_allowlist(&host, &self.allowed_domains) {
@ -2218,14 +2229,14 @@ mod tests {
}
#[test]
fn validate_url_blocks_ipv6_ssrf() {
fn validate_url_allows_ipv6_with_wildcard() {
let security = Arc::new(SecurityPolicy::default());
let tool = BrowserTool::new(security, vec!["*".into()], None);
assert!(tool.validate_url("https://[::1]/").is_err());
assert!(tool.validate_url("https://[::ffff:127.0.0.1]/").is_err());
assert!(tool.validate_url("https://[::1]/").is_ok());
assert!(tool.validate_url("https://[::ffff:127.0.0.1]/").is_ok());
assert!(tool
.validate_url("https://[::ffff:10.0.0.1]:8080/")
.is_err());
.is_ok());
}
#[test]
@ -2298,6 +2309,7 @@ mod tests {
"http://127.0.0.1:9515".into(),
None,
ComputerUseConfig::default(),
crate::tools::NetworkAccessPolicy::default(),
);
assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto);
}
@ -2314,6 +2326,7 @@ mod tests {
"http://127.0.0.1:9515".into(),
None,
ComputerUseConfig::default(),
crate::tools::NetworkAccessPolicy::default(),
);
assert_eq!(
tool.configured_backend().unwrap(),
@ -2336,6 +2349,7 @@ mod tests {
endpoint: "http://computer-use.example.com/v1/actions".into(),
..ComputerUseConfig::default()
},
crate::tools::NetworkAccessPolicy::default(),
);
assert!(tool.computer_use_endpoint_url().is_err());
@ -2357,6 +2371,7 @@ mod tests {
allow_remote_endpoint: true,
..ComputerUseConfig::default()
},
crate::tools::NetworkAccessPolicy::default(),
);
assert!(tool.computer_use_endpoint_url().is_ok());
@ -2378,6 +2393,7 @@ mod tests {
max_coordinate_y: Some(100),
..ComputerUseConfig::default()
},
crate::tools::NetworkAccessPolicy::default(),
);
assert!(tool
@ -2410,15 +2426,15 @@ mod tests {
// Invalid - not in allowlist
assert!(tool.validate_url("https://other.com").is_err());
// Invalid - private host
assert!(tool.validate_url("https://localhost").is_err());
assert!(tool.validate_url("https://127.0.0.1").is_err());
// localhost/private hosts are allowed (not in allowlist though)
assert!(tool.validate_url("https://localhost").is_err()); // not in allowlist
assert!(tool.validate_url("https://127.0.0.1").is_err()); // not in allowlist
// Invalid - not https
// Invalid - unsupported scheme
assert!(tool.validate_url("ftp://example.com").is_err());
// file:// URLs blocked (local file exfiltration risk)
assert!(tool.validate_url("file:///tmp/test.html").is_err());
// file:// URLs are allowed
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
}
#[test]

View File

@ -8,13 +8,19 @@ use std::sync::Arc;
pub struct BrowserOpenTool {
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
net_policy: crate::tools::NetworkAccessPolicy,
}
impl BrowserOpenTool {
pub fn new(security: Arc<SecurityPolicy>, allowed_domains: Vec<String>) -> Self {
pub fn new(
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
net_policy: crate::tools::NetworkAccessPolicy,
) -> Self {
Self {
security,
allowed_domains: normalize_allowed_domains(allowed_domains),
net_policy,
}
}
@ -29,8 +35,16 @@ impl BrowserOpenTool {
anyhow::bail!("URL cannot contain whitespace");
}
if !url.starts_with("https://") {
anyhow::bail!("Only https:// URLs are allowed");
if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("file://") {
anyhow::bail!("Only http://, https://, and file:// URLs are allowed");
}
// file:// URLs require local network access
if url.starts_with("file://") {
if !self.net_policy.allow_local_network {
anyhow::bail!("file:// URLs are blocked (security.allow_local_network = false)");
}
return Ok(url.to_string());
}
if self.allowed_domains.is_empty() {
@ -41,8 +55,13 @@ impl BrowserOpenTool {
let host = extract_host(url)?;
// Apply network access policy
if is_private_or_local_host(&host) {
anyhow::bail!("Blocked local/private host: {host}");
if !self.net_policy.allow_local_network {
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
}
} else if !self.net_policy.allow_public_internet {
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
}
if !host_matches_allowlist(&host, &self.allowed_domains) {
@ -60,7 +79,7 @@ impl Tool for BrowserOpenTool {
}
fn description(&self) -> &str {
"Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
"Open a URL in the system browser. Supports http://, https://, and file:// URLs. Domain allowlist applies to http/https."
}
fn parameters_schema(&self) -> serde_json::Value {
@ -273,7 +292,8 @@ fn normalize_domain(raw: &str) -> Option<String> {
fn extract_host(url: &str) -> anyhow::Result<String> {
let rest = url
.strip_prefix("https://")
.ok_or_else(|| anyhow::anyhow!("Only https:// URLs are allowed"))?;
.or_else(|| url.strip_prefix("http://"))
.ok_or_else(|| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?;
let authority = rest
.split(['/', '?', '#'])
@ -369,6 +389,7 @@ mod tests {
BrowserOpenTool::new(
security,
allowed_domains.into_iter().map(String::from).collect(),
crate::tools::NetworkAccessPolicy::default(),
)
}
@ -408,43 +429,33 @@ mod tests {
}
#[test]
fn validate_wildcard_allowlist_still_rejects_private_host() {
fn validate_wildcard_allowlist_accepts_localhost() {
let tool = test_tool(vec!["*"]);
let err = tool
.validate_url("https://localhost:8443")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://localhost:8443").is_ok());
}
#[test]
fn validate_rejects_http() {
fn validate_accepts_http() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("http://example.com")
.unwrap_err()
.to_string();
assert!(err.contains("https://"));
assert!(tool.validate_url("http://example.com").is_ok());
}
#[test]
fn validate_rejects_localhost() {
fn validate_accepts_localhost() {
let tool = test_tool(vec!["localhost"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://localhost:8080").is_ok());
}
#[test]
fn validate_rejects_private_ipv4() {
fn validate_accepts_private_ipv4() {
let tool = test_tool(vec!["192.168.1.5"]);
let err = tool
.validate_url("https://192.168.1.5")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://192.168.1.5").is_ok());
}
#[test]
fn validate_accepts_file_url() {
let tool = test_tool(vec!["example.com"]);
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
}
#[test]
@ -480,7 +491,7 @@ mod tests {
#[test]
fn validate_requires_allowlist() {
let security = Arc::new(SecurityPolicy::default());
let tool = BrowserOpenTool::new(security, vec![]);
let tool = BrowserOpenTool::new(security, vec![], crate::tools::NetworkAccessPolicy::default());
let err = tool
.validate_url("https://example.com")
.unwrap_err()
@ -506,7 +517,7 @@ mod tests {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = BrowserOpenTool::new(security, vec!["example.com".into()]);
let tool = BrowserOpenTool::new(security, vec!["example.com".into()], crate::tools::NetworkAccessPolicy::default());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
@ -521,7 +532,7 @@ mod tests {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = BrowserOpenTool::new(security, vec!["example.com".into()]);
let tool = BrowserOpenTool::new(security, vec!["example.com".into()], crate::tools::NetworkAccessPolicy::default());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await

View File

@ -12,6 +12,7 @@ pub struct HttpRequestTool {
allowed_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
net_policy: crate::tools::NetworkAccessPolicy,
}
impl HttpRequestTool {
@ -20,12 +21,14 @@ impl HttpRequestTool {
allowed_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
net_policy: crate::tools::NetworkAccessPolicy,
) -> Self {
Self {
security,
allowed_domains: normalize_allowed_domains(allowed_domains),
max_response_size,
timeout_secs,
net_policy,
}
}
@ -52,8 +55,13 @@ impl HttpRequestTool {
let host = extract_host(url)?;
// Apply network access policy
if is_private_or_local_host(&host) {
anyhow::bail!("Blocked local/private host: {host}");
if !self.net_policy.allow_local_network {
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
}
} else if !self.net_policy.allow_public_internet {
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
}
if !host_matches_allowlist(&host, &self.allowed_domains) {
@ -165,8 +173,8 @@ impl Tool for HttpRequestTool {
}
fn description(&self) -> &str {
"Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
"Make HTTP requests to APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
Domain allowlist controls which hosts are reachable. Configurable timeout and response size limits."
}
fn parameters_schema(&self) -> serde_json::Value {
@ -463,6 +471,7 @@ mod tests {
allowed_domains.into_iter().map(String::from).collect(),
1_000_000,
30,
crate::tools::NetworkAccessPolicy::default(),
)
}
@ -508,13 +517,9 @@ mod tests {
}
#[test]
fn validate_wildcard_allowlist_still_rejects_private_host() {
fn validate_wildcard_allowlist_accepts_localhost() {
let tool = test_tool(vec!["*"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://localhost:8080").is_ok());
}
#[test]
@ -528,23 +533,15 @@ mod tests {
}
#[test]
fn validate_rejects_localhost() {
fn validate_accepts_localhost() {
let tool = test_tool(vec!["localhost"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://localhost:8080").is_ok());
}
#[test]
fn validate_rejects_private_ipv4() {
fn validate_accepts_private_ipv4() {
let tool = test_tool(vec!["192.168.1.5"]);
let err = tool
.validate_url("https://192.168.1.5")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://192.168.1.5").is_ok());
}
#[test]
@ -570,7 +567,7 @@ mod tests {
#[test]
fn validate_requires_allowlist() {
let security = Arc::new(SecurityPolicy::default());
let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30);
let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30, crate::tools::NetworkAccessPolicy::default());
let err = tool
.validate_url("https://example.com")
.unwrap_err()
@ -686,7 +683,7 @@ mod tests {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30);
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, crate::tools::NetworkAccessPolicy::default());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
@ -701,7 +698,7 @@ mod tests {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30);
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, crate::tools::NetworkAccessPolicy::default());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
@ -724,6 +721,7 @@ mod tests {
vec!["example.com".into()],
10,
30,
crate::tools::NetworkAccessPolicy::default(),
);
let text = "hello world this is long";
let truncated = tool.truncate_response(text);
@ -738,6 +736,7 @@ mod tests {
vec!["example.com".into()],
0, // max_response_size = 0 means no limit
30,
crate::tools::NetworkAccessPolicy::default(),
);
let text = "a".repeat(10_000_000);
assert_eq!(tool.truncate_response(&text), text);
@ -750,6 +749,7 @@ mod tests {
vec!["example.com".into()],
5,
30,
crate::tools::NetworkAccessPolicy::default(),
);
let text = "hello world";
let truncated = tool.truncate_response(text);

View File

@ -105,6 +105,40 @@ use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
/// Network access policy for tools that make HTTP/file requests.
///
/// Constructed from `[security]` config fields. Both default to `true` (unrestricted).
/// Power users can restrict access via config:
///
/// ```toml
/// [security]
/// allow_local_network = false # blocks localhost, private IPs, file:// URLs
/// allow_public_internet = false # blocks all public internet access
/// ```
#[derive(Debug, Clone, Copy)]
pub struct NetworkAccessPolicy {
pub allow_local_network: bool,
pub allow_public_internet: bool,
}
impl Default for NetworkAccessPolicy {
fn default() -> Self {
Self {
allow_local_network: true,
allow_public_internet: true,
}
}
}
impl NetworkAccessPolicy {
pub fn from_config(config: &crate::config::Config) -> Self {
Self {
allow_local_network: config.security.allow_local_network,
allow_public_internet: config.security.allow_public_internet,
}
}
}
#[derive(Clone)]
struct ArcDelegatingTool {
inner: Arc<dyn Tool>,
@ -209,6 +243,7 @@ pub fn all_tools_with_runtime(
fallback_api_key: Option<&str>,
root_config: &crate::config::Config,
) -> Vec<Box<dyn Tool>> {
let net_policy = NetworkAccessPolicy::from_config(root_config);
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new(security.clone(), runtime)),
Arc::new(FileReadTool::new(security.clone())),
@ -246,6 +281,7 @@ pub fn all_tools_with_runtime(
tool_arcs.push(Arc::new(BrowserOpenTool::new(
security.clone(),
browser_config.allowed_domains.clone(),
net_policy,
)));
// Add full browser automation tool (pluggable backend)
tool_arcs.push(Arc::new(BrowserTool::new_with_backend(
@ -265,6 +301,7 @@ pub fn all_tools_with_runtime(
max_coordinate_x: browser_config.computer_use.max_coordinate_x,
max_coordinate_y: browser_config.computer_use.max_coordinate_y,
},
net_policy,
)));
}
@ -274,6 +311,7 @@ pub fn all_tools_with_runtime(
http_config.allowed_domains.clone(),
http_config.max_response_size,
http_config.timeout_secs,
net_policy,
)));
}
@ -284,6 +322,7 @@ pub fn all_tools_with_runtime(
web_fetch_config.blocked_domains.clone(),
web_fetch_config.max_response_size,
web_fetch_config.timeout_secs,
net_policy,
)));
}

View File

@ -20,6 +20,7 @@ pub struct WebFetchTool {
blocked_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
net_policy: crate::tools::NetworkAccessPolicy,
}
impl WebFetchTool {
@ -29,6 +30,7 @@ impl WebFetchTool {
blocked_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
net_policy: crate::tools::NetworkAccessPolicy,
) -> Self {
Self {
security,
@ -36,6 +38,7 @@ impl WebFetchTool {
blocked_domains: normalize_allowed_domains(blocked_domains),
max_response_size,
timeout_secs,
net_policy,
}
}
@ -45,6 +48,7 @@ impl WebFetchTool {
&self.allowed_domains,
&self.blocked_domains,
"web_fetch",
self.net_policy,
)
}
@ -91,7 +95,7 @@ impl Tool for WebFetchTool {
HTML pages are automatically converted to readable text. \
JSON and plain text responses are returned as-is. \
Only GET requests; follows redirects. \
Security: allowlist-only domains, no local/private hosts."
Domain allowlist controls which hosts are reachable."
}
fn parameters_schema(&self) -> serde_json::Value {
@ -150,6 +154,7 @@ impl Tool for WebFetchTool {
let allowed_domains = self.allowed_domains.clone();
let blocked_domains = self.blocked_domains.clone();
let redirect_net_policy = self.net_policy;
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 10 {
return attempt.error(std::io::Error::other("Too many redirects (max 10)"));
@ -160,6 +165,7 @@ impl Tool for WebFetchTool {
&allowed_domains,
&blocked_domains,
"web_fetch",
redirect_net_policy,
) {
return attempt.error(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
@ -271,6 +277,7 @@ fn validate_target_url(
allowed_domains: &[String],
blocked_domains: &[String],
tool_name: &str,
net_policy: crate::tools::NetworkAccessPolicy,
) -> anyhow::Result<String> {
let url = raw_url.trim();
@ -295,8 +302,13 @@ fn validate_target_url(
let host = extract_host(url)?;
// Apply network access policy
if is_private_or_local_host(&host) {
anyhow::bail!("Blocked local/private host: {host}");
if !net_policy.allow_local_network {
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
}
} else if !net_policy.allow_public_internet {
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
}
if host_matches_allowlist(&host, blocked_domains) {
@ -307,8 +319,6 @@ fn validate_target_url(
anyhow::bail!("Host '{host}' is not in {tool_name}.allowed_domains");
}
validate_resolved_host_is_public(&host)?;
Ok(url.to_string())
}
@ -529,6 +539,7 @@ mod tests {
blocked_domains.into_iter().map(String::from).collect(),
500_000,
30,
crate::tools::NetworkAccessPolicy::default(),
)
}
@ -620,7 +631,7 @@ mod tests {
#[test]
fn validate_requires_allowlist() {
let security = Arc::new(SecurityPolicy::default());
let tool = WebFetchTool::new(security, vec![], vec![], 500_000, 30);
let tool = WebFetchTool::new(security, vec![], vec![], 500_000, 30, crate::tools::NetworkAccessPolicy::default());
let err = tool
.validate_url("https://example.com")
.unwrap_err()
@ -631,46 +642,21 @@ mod tests {
// ── SSRF protection ──────────────────────────────────────────
#[test]
fn ssrf_blocks_localhost() {
fn accepts_localhost() {
let tool = test_tool(vec!["localhost"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://localhost:8080").is_ok());
}
#[test]
fn ssrf_blocks_private_ipv4() {
fn accepts_private_ipv4() {
let tool = test_tool(vec!["192.168.1.5"]);
let err = tool
.validate_url("https://192.168.1.5")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://192.168.1.5").is_ok());
}
#[test]
fn ssrf_blocks_loopback() {
assert!(is_private_or_local_host("127.0.0.1"));
assert!(is_private_or_local_host("127.0.0.2"));
}
#[test]
fn ssrf_blocks_rfc1918() {
assert!(is_private_or_local_host("10.0.0.1"));
assert!(is_private_or_local_host("172.16.0.1"));
assert!(is_private_or_local_host("192.168.1.1"));
}
#[test]
fn ssrf_wildcard_still_blocks_private() {
fn wildcard_accepts_localhost() {
let tool = test_tool(vec!["*"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
assert!(tool.validate_url("https://localhost:8080").is_ok());
}
#[test]
@ -681,28 +667,41 @@ mod tests {
"https://docs.example.com/page",
&allowed,
&blocked,
"web_fetch"
"web_fetch",
crate::tools::NetworkAccessPolicy::default(),
)
.is_ok());
}
#[test]
fn redirect_target_validation_blocks_private_host() {
fn redirect_target_validation_blocks_unlisted_host() {
let allowed = vec!["example.com".to_string()];
let blocked = vec![];
let err = validate_target_url("https://127.0.0.1/admin", &allowed, &blocked, "web_fetch")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
let err = validate_target_url(
"https://127.0.0.1/admin",
&allowed,
&blocked,
"web_fetch",
crate::tools::NetworkAccessPolicy::default(),
)
.unwrap_err()
.to_string();
assert!(err.contains("allowed_domains"));
}
#[test]
fn redirect_target_validation_blocks_blocklisted_host() {
let allowed = vec!["*".to_string()];
let blocked = vec!["evil.com".to_string()];
let err = validate_target_url("https://evil.com/phish", &allowed, &blocked, "web_fetch")
.unwrap_err()
.to_string();
let err = validate_target_url(
"https://evil.com/phish",
&allowed,
&blocked,
"web_fetch",
crate::tools::NetworkAccessPolicy::default(),
)
.unwrap_err()
.to_string();
assert!(err.contains("blocked_domains"));
}
@ -714,7 +713,7 @@ mod tests {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30);
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30, crate::tools::NetworkAccessPolicy::default());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
@ -729,7 +728,7 @@ mod tests {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30);
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30, crate::tools::NetworkAccessPolicy::default());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
@ -755,6 +754,7 @@ mod tests {
vec![],
10,
30,
crate::tools::NetworkAccessPolicy::default(),
);
let text = "hello world this is long";
let truncated = tool.truncate_response(text);

View File

@ -287,7 +287,7 @@ fn config_nested_optional_sections_default_when_absent() {
assert!(parsed.channels_config.telegram.is_none());
assert!(!parsed.composio.enabled);
assert!(parsed.composio.api_key.is_none());
assert!(!parsed.browser.enabled);
assert!(parsed.browser.enabled);
}
#[test]