Skip to main content

construct/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 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/// Configuration for browser delegation (`[browser_delegate]` section).
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24pub struct BrowserDelegateConfig {
25    /// Enable browser delegation tool.
26    #[serde(default)]
27    pub enabled: bool,
28    /// CLI binary to use for browser tasks (default: `"claude"`).
29    #[serde(default = "default_browser_cli")]
30    pub cli_binary: String,
31    /// Chrome profile directory for persistent SSO sessions.
32    #[serde(default)]
33    pub chrome_profile_dir: String,
34    /// Allowed domains for browser navigation (empty = allow all non-blocked).
35    #[serde(default)]
36    pub allowed_domains: Vec<String>,
37    /// Blocked domains for browser navigation.
38    #[serde(default)]
39    pub blocked_domains: Vec<String>,
40    /// Task timeout in seconds.
41    #[serde(default = "default_browser_task_timeout")]
42    pub task_timeout_secs: u64,
43}
44
45/// Default CLI binary for browser delegation.
46fn default_browser_cli() -> String {
47    "claude".into()
48}
49
50/// Default task timeout in seconds (2 minutes).
51fn 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
68/// Tool that delegates browser-based tasks to a browser-capable CLI subprocess.
69pub struct BrowserDelegateTool {
70    security: Arc<SecurityPolicy>,
71    config: BrowserDelegateConfig,
72}
73
74impl BrowserDelegateTool {
75    /// Create a new `BrowserDelegateTool` with the given security policy and config.
76    pub fn new(security: Arc<SecurityPolicy>, config: BrowserDelegateConfig) -> Self {
77        Self { security, config }
78    }
79
80    /// Build the CLI command for a browser task.
81    ///
82    /// Constructs a `tokio::process::Command` with the configured CLI binary,
83    /// `--print` flag for non-interactive mode, and optional Chrome profile env.
84    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        // Claude Code non-interactive mode
88        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        // Set Chrome profile if configured for persistent SSO sessions
105        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    /// Extract URLs from free-form text and validate each against domain policy.
116    ///
117    /// Prevents policy bypass by embedding blocked URLs in the `task` text,
118    /// which is forwarded verbatim to the browser CLI subprocess.
119    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    /// Validate URL against allowed/blocked domain lists and scheme restrictions.
128    ///
129    /// Only `http` and `https` schemes are permitted. Blocked domains take
130    /// precedence over allowed domains when both lists contain the same entry.
131    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        // Only allow http/https schemes
137        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        // Check blocked domains first (deny takes precedence)
149        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 allowed_domains is non-empty, it acts as an allowlist
156        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
174/// Check whether `domain` matches a pattern (exact or suffix match).
175fn 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
181/// Maximum stderr bytes to capture from the subprocess.
182const MAX_STDERR_CHARS: usize = 512;
183
184/// Supported values for the `extract_format` parameter.
185const 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        // Security gate
221        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        // Validate URL if provided
257        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        // Scan task text for embedded URLs and validate against domain policy.
268        // This prevents bypassing domain restrictions by embedding blocked URLs
269        // in the task text, which is forwarded verbatim to the browser CLI.
270        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        // Validate extract_format against allowed enum values
284        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        // Append format instruction to the task
296        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        // Ensure the subprocess is killed when the future is dropped (e.g. on timeout)
304        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
353/// Pre-built task templates for common corporate tools.
354pub struct BrowserTaskTemplates;
355
356impl BrowserTaskTemplates {
357    /// Read messages from a Microsoft Teams channel.
358    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    /// Read emails from the Outlook Web inbox.
368    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    /// Read Jira board for a project.
378    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    /// Read a Confluence page.
387    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    // ── Config defaults ─────────────────────────────────────────────
418
419    #[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    // ── URL validation ──────────────────────────────────────────────
451
452    #[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    // ── Command building ────────────────────────────────────────────
504
505    #[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    // ── Task templates ──────────────────────────────────────────────
558
559    #[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    // ── Domain matching ─────────────────────────────────────────────
589
590    #[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    // ── Execute edge cases ──────────────────────────────────────────
611
612    #[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    // ── URL scheme validation ──────────────────────────────────────
638
639    #[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    // ── Task text URL scanning ──────────────────────────────────────
698
699    #[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    // ── extract_format validation ──────────────────────────────────
748
749    #[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}