Skip to main content

zeroclaw_tools/
browser_delegate.rs

1//! Browser delegation tool.
2//!
3//! Delegates browser-based tasks to a browser-capable CLI subprocess (e.g.
4//! Claude Code with `claude-in-chrome` MCP tools) for interacting with
5//! corporate web applications (Teams, Outlook, Jira, Confluence) that lack
6//! direct API access.
7//!
8//! The tool spawns the configured CLI binary in non-interactive mode, passing
9//! a structured prompt that instructs it to use browser automation. A
10//! persistent Chrome profile can be configured so SSO sessions survive across
11//! invocations.
12
13use 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
22/// Tool that delegates browser-based tasks to a browser-capable CLI subprocess.
23pub struct BrowserDelegateTool {
24    security: Arc<SecurityPolicy>,
25    config: BrowserDelegateConfig,
26}
27
28impl BrowserDelegateTool {
29    /// Create a new `BrowserDelegateTool` with the given security policy and config.
30    pub fn new(security: Arc<SecurityPolicy>, config: BrowserDelegateConfig) -> Self {
31        Self { security, config }
32    }
33
34    /// Build the CLI command for a browser task.
35    ///
36    /// Constructs a `tokio::process::Command` with the configured CLI binary,
37    /// `--print` flag for non-interactive mode, and optional Chrome profile env.
38    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        // Claude Code non-interactive mode
42        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        // Set Chrome profile if configured for persistent SSO sessions
59        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    /// Extract URLs from free-form text and validate each against domain policy.
70    ///
71    /// Prevents policy bypass by embedding blocked URLs in the `task` text,
72    /// which is forwarded verbatim to the browser CLI subprocess.
73    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    /// Validate URL against allowed/blocked domain lists and scheme restrictions.
82    ///
83    /// Only `http` and `https` schemes are permitted. Blocked domains take
84    /// precedence over allowed domains when both lists contain the same entry.
85    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        // Only allow http/https schemes
101        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        // Check blocked domains first (deny takes precedence)
113        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 allowed_domains is non-empty, it acts as an allowlist
120        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
138/// Check whether `domain` matches a pattern (exact or suffix match).
139fn 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
145/// Maximum stderr bytes to capture from the subprocess.
146const MAX_STDERR_CHARS: usize = 512;
147
148/// Supported values for the `extract_format` parameter.
149const 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        // Security gate
185        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        // Validate URL if provided
221        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        // Scan task text for embedded URLs and validate against domain policy.
232        // This prevents bypassing domain restrictions by embedding blocked URLs
233        // in the task text, which is forwarded verbatim to the browser CLI.
234        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        // Validate extract_format against allowed enum values
248        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        // Append format instruction to the task
260        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        // Ensure the subprocess is killed when the future is dropped (e.g. on timeout)
268        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
317/// Pre-built task templates for common corporate tools.
318pub struct BrowserTaskTemplates;
319
320impl BrowserTaskTemplates {
321    /// Read messages from a Microsoft Teams channel.
322    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    /// Read emails from the Outlook Web inbox.
332    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    /// Read Jira board for a project.
342    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    /// Read a Confluence page.
351    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    // ── Config defaults ─────────────────────────────────────────────
382
383    #[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    // ── URL validation ──────────────────────────────────────────────
415
416    #[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    // ── Command building ────────────────────────────────────────────
468
469    #[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    // ── Task templates ──────────────────────────────────────────────
522
523    #[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    // ── Domain matching ─────────────────────────────────────────────
553
554    #[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    // ── Execute edge cases ──────────────────────────────────────────
575
576    #[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    // ── URL scheme validation ──────────────────────────────────────
602
603    #[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    // ── Task text URL scanning ──────────────────────────────────────
662
663    #[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    // ── extract_format validation ──────────────────────────────────
712
713    #[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}