Skip to main content

zeroclaw_tools/
browser_open.rs

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
7/// Open approved HTTPS URLs in the system default browser (no scraping, no DOM automation).
8pub 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        // TODO(compat): remove Brave fallback after default-browser launch has been stable across macOS environments.
146        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        // TODO(compat): remove Brave fallback commands (brave-browser/brave) once default launcher coverage is validated.
197        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        // Use direct process invocation (not `cmd /C start`) to avoid shell
205        // metacharacter interpretation in URLs (e.g. `&` in query strings).
206        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        // TODO(compat): remove Brave fallback after default-browser launch has been stable across Windows environments.
218        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}