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