Skip to main content

construct/tools/
browser_open.rs

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