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

View File

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

View File

@ -8,13 +8,19 @@ use std::sync::Arc;
pub struct BrowserOpenTool { pub struct BrowserOpenTool {
security: Arc<SecurityPolicy>, security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>, allowed_domains: Vec<String>,
net_policy: crate::tools::NetworkAccessPolicy,
} }
impl BrowserOpenTool { 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 { Self {
security, security,
allowed_domains: normalize_allowed_domains(allowed_domains), allowed_domains: normalize_allowed_domains(allowed_domains),
net_policy,
} }
} }
@ -29,8 +35,16 @@ impl BrowserOpenTool {
anyhow::bail!("URL cannot contain whitespace"); anyhow::bail!("URL cannot contain whitespace");
} }
if !url.starts_with("https://") { if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("file://") {
anyhow::bail!("Only https:// URLs are allowed"); 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() { if self.allowed_domains.is_empty() {
@ -41,8 +55,13 @@ impl BrowserOpenTool {
let host = extract_host(url)?; let host = extract_host(url)?;
// Apply network access policy
if is_private_or_local_host(&host) { 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) { if !host_matches_allowlist(&host, &self.allowed_domains) {
@ -60,7 +79,7 @@ impl Tool for BrowserOpenTool {
} }
fn description(&self) -> &str { 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 { 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> { fn extract_host(url: &str) -> anyhow::Result<String> {
let rest = url let rest = url
.strip_prefix("https://") .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 let authority = rest
.split(['/', '?', '#']) .split(['/', '?', '#'])
@ -369,6 +389,7 @@ mod tests {
BrowserOpenTool::new( BrowserOpenTool::new(
security, security,
allowed_domains.into_iter().map(String::from).collect(), allowed_domains.into_iter().map(String::from).collect(),
crate::tools::NetworkAccessPolicy::default(),
) )
} }
@ -408,43 +429,33 @@ mod tests {
} }
#[test] #[test]
fn validate_wildcard_allowlist_still_rejects_private_host() { fn validate_wildcard_allowlist_accepts_localhost() {
let tool = test_tool(vec!["*"]); let tool = test_tool(vec!["*"]);
let err = tool assert!(tool.validate_url("https://localhost:8443").is_ok());
.validate_url("https://localhost:8443")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
} }
#[test] #[test]
fn validate_rejects_http() { fn validate_accepts_http() {
let tool = test_tool(vec!["example.com"]); let tool = test_tool(vec!["example.com"]);
let err = tool assert!(tool.validate_url("http://example.com").is_ok());
.validate_url("http://example.com")
.unwrap_err()
.to_string();
assert!(err.contains("https://"));
} }
#[test] #[test]
fn validate_rejects_localhost() { fn validate_accepts_localhost() {
let tool = test_tool(vec!["localhost"]); let tool = test_tool(vec!["localhost"]);
let err = tool assert!(tool.validate_url("https://localhost:8080").is_ok());
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
} }
#[test] #[test]
fn validate_rejects_private_ipv4() { fn validate_accepts_private_ipv4() {
let tool = test_tool(vec!["192.168.1.5"]); let tool = test_tool(vec!["192.168.1.5"]);
let err = tool assert!(tool.validate_url("https://192.168.1.5").is_ok());
.validate_url("https://192.168.1.5") }
.unwrap_err()
.to_string(); #[test]
assert!(err.contains("local/private")); fn validate_accepts_file_url() {
let tool = test_tool(vec!["example.com"]);
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
} }
#[test] #[test]
@ -480,7 +491,7 @@ mod tests {
#[test] #[test]
fn validate_requires_allowlist() { fn validate_requires_allowlist() {
let security = Arc::new(SecurityPolicy::default()); 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 let err = tool
.validate_url("https://example.com") .validate_url("https://example.com")
.unwrap_err() .unwrap_err()
@ -506,7 +517,7 @@ mod tests {
autonomy: AutonomyLevel::ReadOnly, autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default() ..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 let result = tool
.execute(json!({"url": "https://example.com"})) .execute(json!({"url": "https://example.com"}))
.await .await
@ -521,7 +532,7 @@ mod tests {
max_actions_per_hour: 0, max_actions_per_hour: 0,
..SecurityPolicy::default() ..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 let result = tool
.execute(json!({"url": "https://example.com"})) .execute(json!({"url": "https://example.com"}))
.await .await

View File

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

View File

@ -105,6 +105,40 @@ use async_trait::async_trait;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; 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)] #[derive(Clone)]
struct ArcDelegatingTool { struct ArcDelegatingTool {
inner: Arc<dyn Tool>, inner: Arc<dyn Tool>,
@ -209,6 +243,7 @@ pub fn all_tools_with_runtime(
fallback_api_key: Option<&str>, fallback_api_key: Option<&str>,
root_config: &crate::config::Config, root_config: &crate::config::Config,
) -> Vec<Box<dyn Tool>> { ) -> Vec<Box<dyn Tool>> {
let net_policy = NetworkAccessPolicy::from_config(root_config);
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![ let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new(security.clone(), runtime)), Arc::new(ShellTool::new(security.clone(), runtime)),
Arc::new(FileReadTool::new(security.clone())), Arc::new(FileReadTool::new(security.clone())),
@ -246,6 +281,7 @@ pub fn all_tools_with_runtime(
tool_arcs.push(Arc::new(BrowserOpenTool::new( tool_arcs.push(Arc::new(BrowserOpenTool::new(
security.clone(), security.clone(),
browser_config.allowed_domains.clone(), browser_config.allowed_domains.clone(),
net_policy,
))); )));
// Add full browser automation tool (pluggable backend) // Add full browser automation tool (pluggable backend)
tool_arcs.push(Arc::new(BrowserTool::new_with_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_x: browser_config.computer_use.max_coordinate_x,
max_coordinate_y: browser_config.computer_use.max_coordinate_y, 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.allowed_domains.clone(),
http_config.max_response_size, http_config.max_response_size,
http_config.timeout_secs, 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.blocked_domains.clone(),
web_fetch_config.max_response_size, web_fetch_config.max_response_size,
web_fetch_config.timeout_secs, web_fetch_config.timeout_secs,
net_policy,
))); )));
} }

View File

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