1use crate::security::SecurityPolicy;
14use crate::tools::traits::{Tool, ToolResult};
15use async_trait::async_trait;
16use regex::Regex;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::sync::Arc;
20use tokio::time::{Duration, timeout};
21
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24pub struct BrowserDelegateConfig {
25 #[serde(default)]
27 pub enabled: bool,
28 #[serde(default = "default_browser_cli")]
30 pub cli_binary: String,
31 #[serde(default)]
33 pub chrome_profile_dir: String,
34 #[serde(default)]
36 pub allowed_domains: Vec<String>,
37 #[serde(default)]
39 pub blocked_domains: Vec<String>,
40 #[serde(default = "default_browser_task_timeout")]
42 pub task_timeout_secs: u64,
43}
44
45fn default_browser_cli() -> String {
47 "claude".into()
48}
49
50fn default_browser_task_timeout() -> u64 {
52 120
53}
54
55impl Default for BrowserDelegateConfig {
56 fn default() -> Self {
57 Self {
58 enabled: false,
59 cli_binary: default_browser_cli(),
60 chrome_profile_dir: String::new(),
61 allowed_domains: Vec::new(),
62 blocked_domains: Vec::new(),
63 task_timeout_secs: default_browser_task_timeout(),
64 }
65 }
66}
67
68pub struct BrowserDelegateTool {
70 security: Arc<SecurityPolicy>,
71 config: BrowserDelegateConfig,
72}
73
74impl BrowserDelegateTool {
75 pub fn new(security: Arc<SecurityPolicy>, config: BrowserDelegateConfig) -> Self {
77 Self { security, config }
78 }
79
80 fn build_command(&self, task: &str, url: Option<&str>) -> tokio::process::Command {
85 let mut cmd = tokio::process::Command::new(&self.config.cli_binary);
86
87 cmd.arg("--print");
89
90 let prompt = if let Some(url) = url {
91 format!(
92 "Use your browser tools to navigate to {} and perform the following task: {}",
93 url, task
94 )
95 } else {
96 format!(
97 "Use your browser tools to perform the following task: {}",
98 task
99 )
100 };
101
102 cmd.arg(&prompt);
103
104 if !self.config.chrome_profile_dir.is_empty() {
106 cmd.env("CHROME_USER_DATA_DIR", &self.config.chrome_profile_dir);
107 }
108
109 cmd.stdout(std::process::Stdio::piped());
110 cmd.stderr(std::process::Stdio::piped());
111
112 cmd
113 }
114
115 fn validate_task_urls(&self, task: &str) -> anyhow::Result<()> {
120 let url_re = Regex::new(r#"https?://[^\s\)\]\},\"'`<>]+"#).expect("valid regex");
121 for m in url_re.find_iter(task) {
122 self.validate_url(m.as_str())?;
123 }
124 Ok(())
125 }
126
127 fn validate_url(&self, url: &str) -> anyhow::Result<()> {
132 let parsed = url
133 .parse::<reqwest::Url>()
134 .map_err(|e| anyhow::anyhow!("invalid URL '{}': {}", url, e))?;
135
136 let scheme = parsed.scheme();
138 if scheme != "http" && scheme != "https" {
139 anyhow::bail!("unsupported URL scheme: {}", scheme);
140 }
141
142 let domain = parsed.host_str().unwrap_or("").to_string();
143
144 if domain.is_empty() {
145 anyhow::bail!("URL has no host: {}", url);
146 }
147
148 for blocked in &self.config.blocked_domains {
150 if domain_matches(&domain, blocked) {
151 anyhow::bail!("domain '{}' is blocked by browser_delegate policy", domain);
152 }
153 }
154
155 if !self.config.allowed_domains.is_empty() {
157 let allowed = self
158 .config
159 .allowed_domains
160 .iter()
161 .any(|d| domain_matches(&domain, d));
162 if !allowed {
163 anyhow::bail!(
164 "domain '{}' is not in browser_delegate allowed_domains",
165 domain
166 );
167 }
168 }
169
170 Ok(())
171 }
172}
173
174fn domain_matches(domain: &str, pattern: &str) -> bool {
176 let d = domain.to_lowercase();
177 let p = pattern.to_lowercase();
178 d == p || d.ends_with(&format!(".{}", p))
179}
180
181const MAX_STDERR_CHARS: usize = 512;
183
184const VALID_EXTRACT_FORMATS: &[&str] = &["text", "json", "summary"];
186
187#[async_trait]
188impl Tool for BrowserDelegateTool {
189 fn name(&self) -> &str {
190 "browser_delegate"
191 }
192
193 fn description(&self) -> &str {
194 "Delegate browser-based tasks to a browser-capable CLI for interacting with web applications like Teams, Outlook, Jira, Confluence"
195 }
196
197 fn parameters_schema(&self) -> serde_json::Value {
198 serde_json::json!({
199 "type": "object",
200 "properties": {
201 "task": {
202 "type": "string",
203 "description": "Description of the browser task to perform"
204 },
205 "url": {
206 "type": "string",
207 "description": "Optional URL to navigate to before performing the task"
208 },
209 "extract_format": {
210 "type": "string",
211 "enum": ["text", "json", "summary"],
212 "description": "Desired output format (default: text)"
213 }
214 },
215 "required": ["task"]
216 })
217 }
218
219 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
220 if !self.security.can_act() {
222 return Ok(ToolResult {
223 success: false,
224 output: String::new(),
225 error: Some("browser_delegate tool is denied by security policy".into()),
226 });
227 }
228 if !self.security.record_action() {
229 return Ok(ToolResult {
230 success: false,
231 output: String::new(),
232 error: Some("browser_delegate action rate-limited".into()),
233 });
234 }
235
236 let task = args
237 .get("task")
238 .and_then(serde_json::Value::as_str)
239 .unwrap_or("")
240 .trim();
241
242 if task.is_empty() {
243 return Ok(ToolResult {
244 success: false,
245 output: String::new(),
246 error: Some("'task' parameter is required and cannot be empty".into()),
247 });
248 }
249
250 let url = args
251 .get("url")
252 .and_then(serde_json::Value::as_str)
253 .map(str::trim)
254 .filter(|u| !u.is_empty());
255
256 if let Some(url) = url {
258 if let Err(e) = self.validate_url(url) {
259 return Ok(ToolResult {
260 success: false,
261 output: String::new(),
262 error: Some(format!("URL validation failed: {e}")),
263 });
264 }
265 }
266
267 if let Err(e) = self.validate_task_urls(task) {
271 return Ok(ToolResult {
272 success: false,
273 output: String::new(),
274 error: Some(format!("task text contains a disallowed URL: {e}")),
275 });
276 }
277
278 let extract_format = args
279 .get("extract_format")
280 .and_then(serde_json::Value::as_str)
281 .unwrap_or("text");
282
283 if !VALID_EXTRACT_FORMATS.contains(&extract_format) {
285 return Ok(ToolResult {
286 success: false,
287 output: String::new(),
288 error: Some(format!(
289 "unsupported extract_format '{}': allowed values are 'text', 'json', 'summary'",
290 extract_format
291 )),
292 });
293 }
294
295 let full_task = match extract_format {
297 "json" => format!("{task}. Return the result as structured JSON."),
298 "summary" => format!("{task}. Return a concise summary."),
299 _ => task.to_string(),
300 };
301
302 let mut cmd = self.build_command(&full_task, url);
303 cmd.kill_on_drop(true);
305
306 let deadline = Duration::from_secs(self.config.task_timeout_secs);
307 let result = timeout(deadline, cmd.output()).await;
308
309 match result {
310 Ok(Ok(output)) => {
311 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
312 let stderr = String::from_utf8_lossy(&output.stderr);
313 let stderr_truncated: String = stderr.chars().take(MAX_STDERR_CHARS).collect();
314
315 if output.status.success() {
316 Ok(ToolResult {
317 success: true,
318 output: stdout,
319 error: if stderr_truncated.is_empty() {
320 None
321 } else {
322 Some(stderr_truncated)
323 },
324 })
325 } else {
326 Ok(ToolResult {
327 success: false,
328 output: stdout,
329 error: Some(format!(
330 "CLI exited with status {}: {}",
331 output.status, stderr_truncated
332 )),
333 })
334 }
335 }
336 Ok(Err(e)) => Ok(ToolResult {
337 success: false,
338 output: String::new(),
339 error: Some(format!("failed to spawn browser CLI: {e}")),
340 }),
341 Err(_) => Ok(ToolResult {
342 success: false,
343 output: String::new(),
344 error: Some(format!(
345 "browser task timed out after {}s",
346 self.config.task_timeout_secs
347 )),
348 }),
349 }
350 }
351}
352
353pub struct BrowserTaskTemplates;
355
356impl BrowserTaskTemplates {
357 pub fn read_teams_messages(channel: &str, count: usize) -> String {
359 format!(
360 "Open Microsoft Teams, navigate to the '{}' channel, \
361 read the last {} messages, and return them as a structured \
362 summary with sender, timestamp, and message content.",
363 channel, count
364 )
365 }
366
367 pub fn read_outlook_inbox(count: usize) -> String {
369 format!(
370 "Open Outlook Web (outlook.office.com), go to the inbox, \
371 read the last {} emails, and return a summary of each with \
372 sender, subject, date, and first 2 lines of body.",
373 count
374 )
375 }
376
377 pub fn read_jira_board(project: &str) -> String {
379 format!(
380 "Open Jira, navigate to the '{}' project board, and return \
381 the current sprint tickets with their status, assignee, and title.",
382 project
383 )
384 }
385
386 pub fn read_confluence_page(url: &str) -> String {
388 format!(
389 "Open the Confluence page at {}, read the full content, \
390 and return a structured summary.",
391 url
392 )
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn default_test_config() -> BrowserDelegateConfig {
401 BrowserDelegateConfig::default()
402 }
403
404 fn config_with_domains(allowed: Vec<String>, blocked: Vec<String>) -> BrowserDelegateConfig {
405 BrowserDelegateConfig {
406 enabled: true,
407 allowed_domains: allowed,
408 blocked_domains: blocked,
409 ..BrowserDelegateConfig::default()
410 }
411 }
412
413 fn test_tool(config: BrowserDelegateConfig) -> BrowserDelegateTool {
414 BrowserDelegateTool::new(Arc::new(SecurityPolicy::default()), config)
415 }
416
417 #[test]
420 fn config_defaults_are_sensible() {
421 let cfg = default_test_config();
422 assert!(!cfg.enabled);
423 assert_eq!(cfg.cli_binary, "claude");
424 assert!(cfg.chrome_profile_dir.is_empty());
425 assert!(cfg.allowed_domains.is_empty());
426 assert!(cfg.blocked_domains.is_empty());
427 assert_eq!(cfg.task_timeout_secs, 120);
428 }
429
430 #[test]
431 fn config_serde_roundtrip() {
432 let cfg = BrowserDelegateConfig {
433 enabled: true,
434 cli_binary: "my-cli".into(),
435 chrome_profile_dir: "/tmp/profile".into(),
436 allowed_domains: vec!["example.com".into()],
437 blocked_domains: vec!["evil.com".into()],
438 task_timeout_secs: 60,
439 };
440 let toml_str = toml::to_string(&cfg).unwrap();
441 let parsed: BrowserDelegateConfig = toml::from_str(&toml_str).unwrap();
442 assert!(parsed.enabled);
443 assert_eq!(parsed.cli_binary, "my-cli");
444 assert_eq!(parsed.chrome_profile_dir, "/tmp/profile");
445 assert_eq!(parsed.allowed_domains, vec!["example.com"]);
446 assert_eq!(parsed.blocked_domains, vec!["evil.com"]);
447 assert_eq!(parsed.task_timeout_secs, 60);
448 }
449
450 #[test]
453 fn validate_url_allows_when_no_restrictions() {
454 let tool = test_tool(config_with_domains(vec![], vec![]));
455 assert!(tool.validate_url("https://example.com/page").is_ok());
456 }
457
458 #[test]
459 fn validate_url_rejects_blocked_domain() {
460 let tool = test_tool(config_with_domains(vec![], vec!["evil.com".into()]));
461 let result = tool.validate_url("https://evil.com/phish");
462 assert!(result.is_err());
463 assert!(result.unwrap_err().to_string().contains("blocked"));
464 }
465
466 #[test]
467 fn validate_url_rejects_blocked_subdomain() {
468 let tool = test_tool(config_with_domains(vec![], vec!["evil.com".into()]));
469 assert!(tool.validate_url("https://sub.evil.com/phish").is_err());
470 }
471
472 #[test]
473 fn validate_url_allows_listed_domain() {
474 let tool = test_tool(config_with_domains(vec!["corp.example.com".into()], vec![]));
475 assert!(tool.validate_url("https://corp.example.com/page").is_ok());
476 }
477
478 #[test]
479 fn validate_url_rejects_unlisted_domain_with_allowlist() {
480 let tool = test_tool(config_with_domains(vec!["corp.example.com".into()], vec![]));
481 let result = tool.validate_url("https://other.example.com/page");
482 assert!(result.is_err());
483 assert!(result.unwrap_err().to_string().contains("not in"));
484 }
485
486 #[test]
487 fn validate_url_blocked_takes_precedence_over_allowed() {
488 let tool = test_tool(config_with_domains(
489 vec!["example.com".into()],
490 vec!["example.com".into()],
491 ));
492 let result = tool.validate_url("https://example.com/page");
493 assert!(result.is_err());
494 assert!(result.unwrap_err().to_string().contains("blocked"));
495 }
496
497 #[test]
498 fn validate_url_rejects_invalid_url() {
499 let tool = test_tool(default_test_config());
500 assert!(tool.validate_url("not-a-url").is_err());
501 }
502
503 #[test]
506 fn build_command_uses_configured_binary() {
507 let config = BrowserDelegateConfig {
508 cli_binary: "my-browser-cli".into(),
509 ..BrowserDelegateConfig::default()
510 };
511 let tool = test_tool(config);
512 let cmd = tool.build_command("read inbox", None);
513 assert_eq!(cmd.as_std().get_program(), "my-browser-cli");
514 }
515
516 #[test]
517 fn build_command_includes_print_flag() {
518 let tool = test_tool(default_test_config());
519 let cmd = tool.build_command("read inbox", None);
520 let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
521 assert!(args.contains(&std::ffi::OsStr::new("--print")));
522 }
523
524 #[test]
525 fn build_command_includes_url_in_prompt() {
526 let tool = test_tool(default_test_config());
527 let cmd = tool.build_command("read page", Some("https://example.com"));
528 let args: Vec<String> = cmd
529 .as_std()
530 .get_args()
531 .map(|a| a.to_string_lossy().to_string())
532 .collect();
533 let prompt = args.last().unwrap();
534 assert!(prompt.contains("https://example.com"));
535 assert!(prompt.contains("read page"));
536 }
537
538 #[test]
539 fn build_command_sets_chrome_profile_env() {
540 let config = BrowserDelegateConfig {
541 chrome_profile_dir: "/tmp/chrome-profile".into(),
542 ..BrowserDelegateConfig::default()
543 };
544 let tool = test_tool(config);
545 let cmd = tool.build_command("task", None);
546 let envs: Vec<_> = cmd.as_std().get_envs().collect();
547 let chrome_env = envs
548 .iter()
549 .find(|(k, _)| k == &std::ffi::OsStr::new("CHROME_USER_DATA_DIR"));
550 assert!(chrome_env.is_some());
551 assert_eq!(
552 chrome_env.unwrap().1,
553 Some(std::ffi::OsStr::new("/tmp/chrome-profile"))
554 );
555 }
556
557 #[test]
560 fn template_teams_includes_channel_and_count() {
561 let t = BrowserTaskTemplates::read_teams_messages("engineering", 10);
562 assert!(t.contains("engineering"));
563 assert!(t.contains("10"));
564 assert!(t.contains("Teams"));
565 }
566
567 #[test]
568 fn template_outlook_includes_count() {
569 let t = BrowserTaskTemplates::read_outlook_inbox(5);
570 assert!(t.contains('5'));
571 assert!(t.contains("Outlook"));
572 }
573
574 #[test]
575 fn template_jira_includes_project() {
576 let t = BrowserTaskTemplates::read_jira_board("PROJ-X");
577 assert!(t.contains("PROJ-X"));
578 assert!(t.contains("Jira"));
579 }
580
581 #[test]
582 fn template_confluence_includes_url() {
583 let t = BrowserTaskTemplates::read_confluence_page("https://wiki.example.com/page/123");
584 assert!(t.contains("https://wiki.example.com/page/123"));
585 assert!(t.contains("Confluence"));
586 }
587
588 #[test]
591 fn domain_matches_exact() {
592 assert!(domain_matches("example.com", "example.com"));
593 }
594
595 #[test]
596 fn domain_matches_subdomain() {
597 assert!(domain_matches("sub.example.com", "example.com"));
598 }
599
600 #[test]
601 fn domain_matches_case_insensitive() {
602 assert!(domain_matches("Example.COM", "example.com"));
603 }
604
605 #[test]
606 fn domain_does_not_match_partial() {
607 assert!(!domain_matches("notexample.com", "example.com"));
608 }
609
610 #[tokio::test]
613 async fn execute_rejects_empty_task() {
614 let tool = test_tool(default_test_config());
615 let result = tool
616 .execute(serde_json::json!({ "task": "" }))
617 .await
618 .unwrap();
619 assert!(!result.success);
620 assert!(result.error.as_deref().unwrap().contains("required"));
621 }
622
623 #[tokio::test]
624 async fn execute_rejects_blocked_url() {
625 let tool = test_tool(config_with_domains(vec![], vec!["evil.com".into()]));
626 let result = tool
627 .execute(serde_json::json!({
628 "task": "read page",
629 "url": "https://evil.com/page"
630 }))
631 .await
632 .unwrap();
633 assert!(!result.success);
634 assert!(result.error.as_deref().unwrap().contains("blocked"));
635 }
636
637 #[test]
640 fn validate_url_rejects_ftp_scheme() {
641 let tool = test_tool(config_with_domains(vec![], vec![]));
642 let result = tool.validate_url("ftp://example.com/file");
643 assert!(result.is_err());
644 assert!(
645 result
646 .unwrap_err()
647 .to_string()
648 .contains("unsupported URL scheme")
649 );
650 }
651
652 #[test]
653 fn validate_url_rejects_file_scheme() {
654 let tool = test_tool(config_with_domains(vec![], vec![]));
655 let result = tool.validate_url("file:///etc/passwd");
656 assert!(result.is_err());
657 assert!(
658 result
659 .unwrap_err()
660 .to_string()
661 .contains("unsupported URL scheme")
662 );
663 }
664
665 #[test]
666 fn validate_url_rejects_javascript_scheme() {
667 let tool = test_tool(config_with_domains(vec![], vec![]));
668 let result = tool.validate_url("javascript:alert(1)");
669 assert!(result.is_err());
670 assert!(
671 result
672 .unwrap_err()
673 .to_string()
674 .contains("unsupported URL scheme")
675 );
676 }
677
678 #[test]
679 fn validate_url_rejects_data_scheme() {
680 let tool = test_tool(config_with_domains(vec![], vec![]));
681 let result = tool.validate_url("data:text/html,<h1>hi</h1>");
682 assert!(result.is_err());
683 assert!(
684 result
685 .unwrap_err()
686 .to_string()
687 .contains("unsupported URL scheme")
688 );
689 }
690
691 #[test]
692 fn validate_url_allows_http_scheme() {
693 let tool = test_tool(config_with_domains(vec![], vec![]));
694 assert!(tool.validate_url("http://example.com/page").is_ok());
695 }
696
697 #[test]
700 fn validate_task_urls_blocks_embedded_blocked_url() {
701 let tool = test_tool(config_with_domains(vec![], vec!["evil.com".into()]));
702 let result = tool.validate_task_urls("go to https://evil.com/steal and read it");
703 assert!(result.is_err());
704 assert!(result.unwrap_err().to_string().contains("blocked"));
705 }
706
707 #[test]
708 fn validate_task_urls_blocks_embedded_url_not_in_allowlist() {
709 let tool = test_tool(config_with_domains(vec!["corp.example.com".into()], vec![]));
710 let result =
711 tool.validate_task_urls("navigate to https://attacker.com/page and extract data");
712 assert!(result.is_err());
713 assert!(result.unwrap_err().to_string().contains("not in"));
714 }
715
716 #[test]
717 fn validate_task_urls_allows_permitted_embedded_url() {
718 let tool = test_tool(config_with_domains(vec!["corp.example.com".into()], vec![]));
719 assert!(
720 tool.validate_task_urls("read https://corp.example.com/page and summarize")
721 .is_ok()
722 );
723 }
724
725 #[test]
726 fn validate_task_urls_allows_text_without_urls() {
727 let tool = test_tool(config_with_domains(vec![], vec!["evil.com".into()]));
728 assert!(
729 tool.validate_task_urls("read the last 10 messages from engineering channel")
730 .is_ok()
731 );
732 }
733
734 #[tokio::test]
735 async fn execute_rejects_blocked_url_in_task_text() {
736 let tool = test_tool(config_with_domains(vec![], vec!["evil.com".into()]));
737 let result = tool
738 .execute(serde_json::json!({
739 "task": "navigate to https://evil.com/phish and extract credentials"
740 }))
741 .await
742 .unwrap();
743 assert!(!result.success);
744 assert!(result.error.as_deref().unwrap().contains("disallowed URL"));
745 }
746
747 #[tokio::test]
750 async fn execute_rejects_invalid_extract_format() {
751 let tool = test_tool(default_test_config());
752 let result = tool
753 .execute(serde_json::json!({
754 "task": "read page",
755 "extract_format": "xml"
756 }))
757 .await
758 .unwrap();
759 assert!(!result.success);
760 assert!(
761 result
762 .error
763 .as_deref()
764 .unwrap()
765 .contains("unsupported extract_format")
766 );
767 assert!(result.error.as_deref().unwrap().contains("xml"));
768 }
769}