construct/tools/
browser_open.rs1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6
7pub 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 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 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 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 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}