From 9067da5a168aecbe8d34def887027e42ff55823d Mon Sep 17 00:00:00 2001 From: jordanthejet Date: Sat, 7 Mar 2026 22:05:45 -0500 Subject: [PATCH] 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 --- src/config/schema.rs | 56 ++++++++++++++--------- src/tools/browser.rs | 54 ++++++++++++++-------- src/tools/browser_open.rs | 77 ++++++++++++++++++-------------- src/tools/http_request.rs | 48 ++++++++++---------- src/tools/mod.rs | 39 ++++++++++++++++ src/tools/web_fetch.rs | 94 +++++++++++++++++++-------------------- 6 files changed, 224 insertions(+), 144 deletions(-) diff --git a/src/config/schema.rs b/src/config/schema.rs index 6366a169d..bd0aba3a9 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -834,6 +834,10 @@ fn default_true() -> bool { true } +fn default_wildcard_domains() -> Vec { + 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, /// 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, /// 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) ───────── diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 62a7cb6a0..3ca9ca44c 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -67,6 +67,7 @@ pub struct BrowserTool { native_webdriver_url: String, native_chrome_path: Option, computer_use: ComputerUseConfig, + net_policy: super::NetworkAccessPolicy, #[cfg(feature = "browser-native")] native_state: tokio::sync::Mutex, } @@ -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, 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] diff --git a/src/tools/browser_open.rs b/src/tools/browser_open.rs index 7ac5013f7..4ff644df4 100644 --- a/src/tools/browser_open.rs +++ b/src/tools/browser_open.rs @@ -8,13 +8,19 @@ use std::sync::Arc; pub struct BrowserOpenTool { security: Arc, allowed_domains: Vec, + net_policy: crate::tools::NetworkAccessPolicy, } impl BrowserOpenTool { - pub fn new(security: Arc, allowed_domains: Vec) -> Self { + pub fn new( + security: Arc, + allowed_domains: Vec, + 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 { fn extract_host(url: &str) -> anyhow::Result { 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 diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 513ba554b..d5f299ac2 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -12,6 +12,7 @@ pub struct HttpRequestTool { allowed_domains: Vec, max_response_size: usize, timeout_secs: u64, + net_policy: crate::tools::NetworkAccessPolicy, } impl HttpRequestTool { @@ -20,12 +21,14 @@ impl HttpRequestTool { allowed_domains: Vec, 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); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0164bdda4..fdaf14c65 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -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, @@ -209,6 +243,7 @@ pub fn all_tools_with_runtime( fallback_api_key: Option<&str>, root_config: &crate::config::Config, ) -> Vec> { + let net_policy = NetworkAccessPolicy::from_config(root_config); let mut tool_arcs: Vec> = 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, ))); } diff --git a/src/tools/web_fetch.rs b/src/tools/web_fetch.rs index a93a9d4ba..88160a189 100644 --- a/src/tools/web_fetch.rs +++ b/src/tools/web_fetch.rs @@ -20,6 +20,7 @@ pub struct WebFetchTool { blocked_domains: Vec, max_response_size: usize, timeout_secs: u64, + net_policy: crate::tools::NetworkAccessPolicy, } impl WebFetchTool { @@ -29,6 +30,7 @@ impl WebFetchTool { blocked_domains: Vec, 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 { 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);