1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use zeroclaw_api::tool::{Tool, ToolResult};
5use zeroclaw_config::policy::SecurityPolicy;
6
7pub struct BrowserOpenTool {
9 security: Arc<SecurityPolicy>,
10 allowed_domains: Vec<String>,
11}
12
13impl BrowserOpenTool {
14 pub fn new(
15 security: Arc<SecurityPolicy>,
16 allowed_domains: Vec<String>,
17 ) -> anyhow::Result<Self> {
18 Ok(Self {
19 security,
20 allowed_domains: normalize_allowed_domains(allowed_domains)?,
21 })
22 }
23
24 fn validate_url(&self, raw_url: &str) -> anyhow::Result<String> {
25 let url = raw_url.trim();
26
27 if url.is_empty() {
28 anyhow::bail!("URL cannot be empty");
29 }
30
31 if url.chars().any(char::is_whitespace) {
32 anyhow::bail!("URL cannot contain whitespace");
33 }
34
35 if !url.starts_with("https://") {
36 anyhow::bail!("Only https:// URLs are allowed");
37 }
38
39 if self.allowed_domains.is_empty() {
40 anyhow::bail!(
41 "Browser tool is enabled but no allowed_domains are configured. Add [browser].allowed_domains in config.toml"
42 );
43 }
44
45 let host = extract_host(url)?;
46
47 if is_private_or_local_host(&host) {
48 anyhow::bail!("Blocked local/private host: {host}");
49 }
50
51 if !host_matches_allowlist(&host, &self.allowed_domains) {
52 anyhow::bail!("Host '{host}' is not in browser.allowed_domains");
53 }
54
55 Ok(url.to_string())
56 }
57}
58
59#[async_trait]
60impl Tool for BrowserOpenTool {
61 fn name(&self) -> &str {
62 "browser_open"
63 }
64
65 fn description(&self) -> &str {
66 "Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
67 }
68
69 fn parameters_schema(&self) -> serde_json::Value {
70 json!({
71 "type": "object",
72 "properties": {
73 "url": {
74 "type": "string",
75 "description": "HTTPS URL to open in the system browser"
76 }
77 },
78 "required": ["url"]
79 })
80 }
81
82 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
83 let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
84 ::zeroclaw_log::record!(
85 WARN,
86 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
87 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
88 .with_attrs(::serde_json::json!({"param": "url"})),
89 "browser_open: missing url parameter"
90 );
91 anyhow::Error::msg("Missing 'url' parameter")
92 })?;
93
94 if !self.security.can_act() {
95 return Ok(ToolResult {
96 success: false,
97 output: String::new(),
98 error: Some("Action blocked: autonomy is read-only".into()),
99 });
100 }
101
102 if !self.security.record_action() {
103 return Ok(ToolResult {
104 success: false,
105 output: String::new(),
106 error: Some("Action blocked: rate limit exceeded".into()),
107 });
108 }
109
110 let url = match self.validate_url(url) {
111 Ok(v) => v,
112 Err(e) => {
113 return Ok(ToolResult {
114 success: false,
115 output: String::new(),
116 error: Some(e.to_string()),
117 });
118 }
119 };
120
121 match open_in_system_browser(&url).await {
122 Ok(()) => Ok(ToolResult {
123 success: true,
124 output: format!("Opened in system browser: {url}"),
125 error: None,
126 }),
127 Err(e) => Ok(ToolResult {
128 success: false,
129 output: String::new(),
130 error: Some(format!("Failed to open system browser: {e}")),
131 }),
132 }
133 }
134}
135
136async fn open_in_system_browser(url: &str) -> anyhow::Result<()> {
137 #[cfg(target_os = "macos")]
138 {
139 let primary_error = match tokio::process::Command::new("open").arg(url).status().await {
140 Ok(status) if status.success() => return Ok(()),
141 Ok(status) => format!("open exited with status {status}"),
142 Err(error) => format!("open not runnable: {error}"),
143 };
144
145 let mut brave_error = String::new();
147 for app in ["Brave Browser", "Brave"] {
148 match tokio::process::Command::new("open")
149 .arg("-a")
150 .arg(app)
151 .arg(url)
152 .status()
153 .await
154 {
155 Ok(status) if status.success() => return Ok(()),
156 Ok(status) => {
157 brave_error = format!("open -a '{app}' exited with status {status}");
158 }
159 Err(error) => {
160 brave_error = format!("open -a '{app}' not runnable: {error}");
161 }
162 }
163 }
164
165 anyhow::bail!(
166 "Failed to open URL with default browser launcher: {primary_error}. Brave compatibility fallback also failed: {brave_error}"
167 );
168 }
169
170 #[cfg(target_os = "linux")]
171 {
172 let mut last_error = String::new();
173 for cmd in [
174 "xdg-open",
175 "gio",
176 "sensible-browser",
177 "brave-browser",
178 "brave",
179 ] {
180 let mut command = tokio::process::Command::new(cmd);
181 if cmd == "gio" {
182 command.arg("open");
183 }
184 command.arg(url);
185 match command.status().await {
186 Ok(status) if status.success() => return Ok(()),
187 Ok(status) => {
188 last_error = format!("{cmd} exited with status {status}");
189 }
190 Err(error) => {
191 last_error = format!("{cmd} not runnable: {error}");
192 }
193 }
194 }
195
196 anyhow::bail!(
198 "Failed to open URL with default browser launchers; Brave compatibility fallback also failed. Last error: {last_error}"
199 );
200 }
201
202 #[cfg(target_os = "windows")]
203 {
204 let primary_error = match tokio::process::Command::new("rundll32")
207 .arg("url.dll,FileProtocolHandler")
208 .arg(url)
209 .status()
210 .await
211 {
212 Ok(status) if status.success() => return Ok(()),
213 Ok(status) => format!("rundll32 default-browser launcher exited with status {status}"),
214 Err(error) => format!("rundll32 default-browser launcher not runnable: {error}"),
215 };
216
217 let mut brave_error = String::new();
219 for cmd in ["brave", "brave.exe"] {
220 match tokio::process::Command::new(cmd).arg(url).status().await {
221 Ok(status) if status.success() => return Ok(()),
222 Ok(status) => {
223 brave_error = format!("{cmd} exited with status {status}");
224 }
225 Err(error) => {
226 brave_error = format!("{cmd} not runnable: {error}");
227 }
228 }
229 }
230
231 anyhow::bail!(
232 "Failed to open URL with default browser launcher: {primary_error}. Brave compatibility fallback also failed: {brave_error}"
233 );
234 }
235
236 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
237 {
238 let _ = url;
239 anyhow::bail!("browser_open is not supported on this OS");
240 }
241}
242
243fn normalize_allowed_domains(domains: Vec<String>) -> anyhow::Result<Vec<String>> {
244 let mut rejected = Vec::new();
245 let mut normalized = domains
246 .into_iter()
247 .filter_map(|d| {
248 normalize_domain(&d).or_else(|| {
249 rejected.push(d.clone());
250 None
251 })
252 })
253 .collect::<Vec<_>>();
254 if !rejected.is_empty() {
255 anyhow::bail!(
256 "Invalid browser.allowed_domains entry(s): [{}]. Each entry must be a valid domain, hostname, IPv4, or IPv6 address.",
257 rejected.join(", ")
258 );
259 }
260 normalized.sort_unstable();
261 normalized.dedup();
262 Ok(normalized)
263}
264
265fn normalize_domain(raw: &str) -> Option<String> {
266 let input = raw.trim();
267 if input.is_empty() || input.chars().any(char::is_whitespace) {
268 return None;
269 }
270
271 let bare_ip = match (input.starts_with('['), input.ends_with(']')) {
272 (true, true) => &input[1..input.len() - 1],
273 (false, false) => input,
274 _ => return None,
275 };
276 if let Ok(ip) = bare_ip.parse::<std::net::IpAddr>() {
277 return Some(ip.to_string().to_lowercase());
278 }
279
280 let parsed = reqwest::Url::parse(input)
281 .or_else(|_| reqwest::Url::parse(&format!("https://{input}")))
282 .ok()?;
283
284 if !parsed.username().is_empty() || parsed.password().is_some() {
285 return None;
286 }
287
288 let host = parsed.host_str()?;
289 let trimmed = host.trim();
290 let host_no_brackets = match (trimmed.starts_with('['), trimmed.ends_with(']')) {
291 (true, true) => &trimmed[1..trimmed.len() - 1],
292 (false, false) => trimmed,
293 _ => return None,
294 };
295 let normalized = host_no_brackets
296 .trim_start_matches('.')
297 .trim_end_matches('.');
298 if normalized.is_empty() {
299 return None;
300 }
301
302 Some(normalized.to_lowercase())
303}
304
305fn extract_host(url: &str) -> anyhow::Result<String> {
306 let rest = url.strip_prefix("https://").ok_or_else(|| {
307 ::zeroclaw_log::record!(
308 WARN,
309 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
310 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
311 .with_attrs(::serde_json::json!({"url": url})),
312 "browser_open: non-https URL rejected"
313 );
314 anyhow::Error::msg("Only https:// URLs are allowed")
315 })?;
316
317 let authority = rest.split(['/', '?', '#']).next().ok_or_else(|| {
318 ::zeroclaw_log::record!(
319 WARN,
320 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
321 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
322 .with_attrs(::serde_json::json!({"url": url})),
323 "browser_open: invalid URL"
324 );
325 anyhow::Error::msg("Invalid URL")
326 })?;
327
328 if authority.is_empty() {
329 anyhow::bail!("URL must include a host");
330 }
331
332 if authority.contains('@') {
333 anyhow::bail!("URL userinfo is not allowed");
334 }
335
336 if authority.starts_with('[') {
337 anyhow::bail!("IPv6 hosts are not supported in browser_open");
338 }
339
340 let host = authority
341 .split(':')
342 .next()
343 .unwrap_or_default()
344 .trim()
345 .trim_end_matches('.')
346 .to_lowercase();
347
348 if host.is_empty() {
349 anyhow::bail!("URL must include a valid host");
350 }
351
352 Ok(host)
353}
354
355fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool {
356 if allowed_domains.iter().any(|domain| domain == "*") {
357 return true;
358 }
359
360 allowed_domains.iter().any(|domain| {
361 host == domain
362 || host
363 .strip_suffix(domain)
364 .is_some_and(|prefix| prefix.ends_with('.'))
365 })
366}
367
368fn is_private_or_local_host(host: &str) -> bool {
369 let has_local_tld = host
370 .rsplit('.')
371 .next()
372 .is_some_and(|label| label == "local");
373
374 if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" {
375 return true;
376 }
377
378 if let Some([a, b, _, _]) = parse_ipv4(host) {
379 return a == 0
380 || a == 10
381 || a == 127
382 || (a == 169 && b == 254)
383 || (a == 172 && (16..=31).contains(&b))
384 || (a == 192 && b == 168)
385 || (a == 100 && (64..=127).contains(&b));
386 }
387
388 false
389}
390
391fn parse_ipv4(host: &str) -> Option<[u8; 4]> {
392 let parts: Vec<&str> = host.split('.').collect();
393 if parts.len() != 4 {
394 return None;
395 }
396
397 let mut octets = [0_u8; 4];
398 for (i, part) in parts.iter().enumerate() {
399 octets[i] = part.parse::<u8>().ok()?;
400 }
401 Some(octets)
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use zeroclaw_config::autonomy::AutonomyLevel;
408 use zeroclaw_config::policy::SecurityPolicy;
409
410 fn test_tool(allowed_domains: Vec<&str>) -> BrowserOpenTool {
411 let security = Arc::new(SecurityPolicy {
412 autonomy: AutonomyLevel::Supervised,
413 ..SecurityPolicy::default()
414 });
415 BrowserOpenTool::new(
416 security,
417 allowed_domains.into_iter().map(String::from).collect(),
418 )
419 .unwrap()
420 }
421
422 #[test]
423 fn normalize_domain_strips_scheme_path_and_case() {
424 let got = normalize_domain(" HTTPS://Docs.Example.com/path ").unwrap();
425 assert_eq!(got, "docs.example.com");
426 }
427
428 #[test]
429 fn normalize_domain_rejects_userinfo() {
430 assert!(normalize_domain("https://user@example.com").is_none());
431 assert!(normalize_domain("user@example.com").is_none());
432 assert!(normalize_domain("https://user:pass@example.com").is_none());
433 assert!(normalize_domain("user:pass@example.com").is_none());
434 }
435
436 #[test]
437 fn normalize_domain_rejects_unmatched_brackets() {
438 assert!(normalize_domain("[::1").is_none());
439 assert!(normalize_domain("::1]").is_none());
440 assert!(normalize_domain("[127.0.0.1").is_none());
441 assert!(normalize_domain("127.0.0.1]").is_none());
442 }
443
444 #[test]
445 fn normalize_allowed_domains_deduplicates() {
446 let got = normalize_allowed_domains(vec![
447 "example.com".into(),
448 "EXAMPLE.COM".into(),
449 "https://example.com/".into(),
450 ])
451 .unwrap();
452 assert_eq!(got, vec!["example.com".to_string()]);
453 }
454
455 #[test]
456 fn validate_accepts_exact_domain() {
457 let tool = test_tool(vec!["example.com"]);
458 let got = tool.validate_url("https://example.com/docs").unwrap();
459 assert_eq!(got, "https://example.com/docs");
460 }
461
462 #[test]
463 fn validate_accepts_subdomain() {
464 let tool = test_tool(vec!["example.com"]);
465 assert!(tool.validate_url("https://api.example.com/v1").is_ok());
466 }
467
468 #[test]
469 fn validate_accepts_wildcard_allowlist_for_public_host() {
470 let tool = test_tool(vec!["*"]);
471 assert!(tool.validate_url("https://www.rust-lang.org").is_ok());
472 }
473
474 #[test]
475 fn validate_wildcard_allowlist_still_rejects_private_host() {
476 let tool = test_tool(vec!["*"]);
477 let err = tool
478 .validate_url("https://localhost:8443")
479 .unwrap_err()
480 .to_string();
481 assert!(err.contains("local/private"));
482 }
483
484 #[test]
485 fn validate_rejects_http() {
486 let tool = test_tool(vec!["example.com"]);
487 let err = tool
488 .validate_url("http://example.com")
489 .unwrap_err()
490 .to_string();
491 assert!(err.contains("https://"));
492 }
493
494 #[test]
495 fn validate_rejects_localhost() {
496 let tool = test_tool(vec!["localhost"]);
497 let err = tool
498 .validate_url("https://localhost:8080")
499 .unwrap_err()
500 .to_string();
501 assert!(err.contains("local/private"));
502 }
503
504 #[test]
505 fn validate_rejects_private_ipv4() {
506 let tool = test_tool(vec!["192.168.1.5"]);
507 let err = tool
508 .validate_url("https://192.168.1.5")
509 .unwrap_err()
510 .to_string();
511 assert!(err.contains("local/private"));
512 }
513
514 #[test]
515 fn validate_rejects_allowlist_miss() {
516 let tool = test_tool(vec!["example.com"]);
517 let err = tool
518 .validate_url("https://google.com")
519 .unwrap_err()
520 .to_string();
521 assert!(err.contains("allowed_domains"));
522 }
523
524 #[test]
525 fn validate_rejects_whitespace() {
526 let tool = test_tool(vec!["example.com"]);
527 let err = tool
528 .validate_url("https://example.com/hello world")
529 .unwrap_err()
530 .to_string();
531 assert!(err.contains("whitespace"));
532 }
533
534 #[test]
535 fn validate_rejects_userinfo() {
536 let tool = test_tool(vec!["example.com"]);
537 let err = tool
538 .validate_url("https://user@example.com")
539 .unwrap_err()
540 .to_string();
541 assert!(err.contains("userinfo"));
542 }
543
544 #[test]
545 fn validate_requires_allowlist() {
546 let security = Arc::new(SecurityPolicy::default());
547 let tool = BrowserOpenTool::new(security, vec![]).unwrap();
548 let err = tool
549 .validate_url("https://example.com")
550 .unwrap_err()
551 .to_string();
552 assert!(err.contains("allowed_domains"));
553 }
554
555 #[test]
556 fn parse_ipv4_valid() {
557 assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4]));
558 }
559
560 #[test]
561 fn parse_ipv4_invalid() {
562 assert_eq!(parse_ipv4("1.2.3"), None);
563 assert_eq!(parse_ipv4("1.2.3.999"), None);
564 assert_eq!(parse_ipv4("not-an-ip"), None);
565 }
566
567 #[tokio::test]
568 async fn execute_blocks_readonly_mode() {
569 let security = Arc::new(SecurityPolicy {
570 autonomy: AutonomyLevel::ReadOnly,
571 ..SecurityPolicy::default()
572 });
573 let tool = BrowserOpenTool::new(security, vec!["example.com".into()]).unwrap();
574 let result = tool
575 .execute(json!({"url": "https://example.com"}))
576 .await
577 .unwrap();
578 assert!(!result.success);
579 assert!(result.error.unwrap().contains("read-only"));
580 }
581
582 #[tokio::test]
583 async fn execute_blocks_when_rate_limited() {
584 let security = Arc::new(SecurityPolicy {
585 max_actions_per_hour: 0,
586 ..SecurityPolicy::default()
587 });
588 let tool = BrowserOpenTool::new(security, vec!["example.com".into()]).unwrap();
589 let result = tool
590 .execute(json!({"url": "https://example.com"}))
591 .await
592 .unwrap();
593 assert!(!result.success);
594 assert!(result.error.unwrap().contains("rate limit"));
595 }
596}