Skip to main content

chio_guards/
browser_automation.rs

1//! BrowserAutomationGuard -- domain allowlists, action-type restrictions,
2//! and credential detection in `Type` actions.
3//!
4//! Complements the coarse
5//! [`crate::computer_use::ComputerUseGuard`] with fine-grained rules
6//! specifically for browser-automation tool calls:
7//!
8//! - **Navigation gating**: URLs whose host is outside
9//!   [`BrowserAutomationConfig::allowed_domains`] are denied.
10//! - **Verb gating**: [`BrowserAutomationConfig::allowed_verbs`] restricts
11//!   the action verbs an agent may issue (e.g. read-only sessions
12//!   permit `navigate` + `screenshot` but deny `type` / `click`).
13//! - **Credential detection**: `type` / `input` actions whose text
14//!   looks like a secret (API key, bearer token, PEM key, AWS key,
15//!   high-entropy password) are denied.
16//!
17//! Calls that are not [`ToolAction::BrowserAction`] pass through with
18//! [`Verdict::Allow`] so the guard composes cleanly with the rest of
19//! the pipeline.
20//!
21//! # Fail-closed semantics
22//!
23//! - navigation verbs (`navigate`/`goto`/`open`) without a parseable
24//!   target URL are denied when a non-empty
25//!   [`BrowserAutomationConfig::allowed_domains`] list is configured;
26//! - `type` actions with no `value` / `text` argument are allowed (the
27//!   guard has nothing to inspect);
28//! - malformed credential regex configuration causes
29//!   [`BrowserAutomationGuard::with_config`] to return
30//!   [`BrowserAutomationError::InvalidPattern`].
31
32use std::collections::HashSet;
33use std::sync::OnceLock;
34
35use regex::Regex;
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
40
41use crate::action::{extract_action_checked, ToolAction};
42
43/// Default allowed verbs (open browser session, navigate, read state).
44pub fn default_allowed_verbs() -> Vec<String> {
45    vec![
46        "navigate".to_string(),
47        "goto".to_string(),
48        "open".to_string(),
49        "screenshot".to_string(),
50        "screen_capture".to_string(),
51        "capture".to_string(),
52        "browser_screenshot".to_string(),
53        "get_url".to_string(),
54        "get_title".to_string(),
55        "read".to_string(),
56        "get_content".to_string(),
57        "close".to_string(),
58        "back".to_string(),
59        "forward".to_string(),
60        "reload".to_string(),
61    ]
62}
63
64/// Errors produced when building a [`BrowserAutomationGuard`].
65#[derive(Debug, thiserror::Error)]
66pub enum BrowserAutomationError {
67    /// A user-supplied credential pattern was not a valid regex.
68    #[error("invalid credential pattern `{pattern}`: {source}")]
69    InvalidPattern {
70        pattern: String,
71        #[source]
72        source: regex::Error,
73    },
74}
75
76/// Configuration for [`BrowserAutomationGuard`].
77#[derive(Clone, Debug, Deserialize, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct BrowserAutomationConfig {
80    /// Enable/disable the guard entirely.
81    #[serde(default = "default_true")]
82    pub enabled: bool,
83    /// Hosts the agent may navigate to.  Supports exact match and
84    /// `*.suffix` wildcard patterns.  Empty means "no allowlist"
85    /// (navigation check is skipped).
86    #[serde(default)]
87    pub allowed_domains: Vec<String>,
88    /// Blocked hosts (always denied, evaluated before the allowlist).
89    #[serde(default)]
90    pub blocked_domains: Vec<String>,
91    /// Verbs (actions) the agent may issue.  Empty means "any verb".
92    #[serde(default = "default_allowed_verbs")]
93    pub allowed_verbs: Vec<String>,
94    /// When `true`, check `type` / `input` action values for
95    /// credential-shaped secrets.
96    #[serde(default = "default_true")]
97    pub credential_detection: bool,
98    /// Extra credential regex patterns layered on top of the built-in
99    /// detectors.  Invalid regexes cause initialization to fail.
100    #[serde(default)]
101    pub extra_credential_patterns: Vec<String>,
102}
103
104fn default_true() -> bool {
105    true
106}
107
108impl Default for BrowserAutomationConfig {
109    fn default() -> Self {
110        Self {
111            enabled: true,
112            allowed_domains: Vec::new(),
113            blocked_domains: Vec::new(),
114            allowed_verbs: default_allowed_verbs(),
115            credential_detection: true,
116            extra_credential_patterns: Vec::new(),
117        }
118    }
119}
120
121/// Guard that enforces browser-automation policy per
122/// [`BrowserAutomationConfig`].
123pub struct BrowserAutomationGuard {
124    enabled: bool,
125    allowed_domains: Vec<String>,
126    blocked_domains: Vec<String>,
127    allowed_verbs: HashSet<String>,
128    credential_detection: bool,
129    extra_patterns: Vec<Regex>,
130}
131
132impl BrowserAutomationGuard {
133    /// Build a guard with default configuration.
134    pub fn new() -> Self {
135        match Self::with_config(BrowserAutomationConfig::default()) {
136            Ok(g) => g,
137            Err(_) => Self::empty_failclosed(),
138        }
139    }
140
141    /// Build an empty guard that denies every browser action.  Defensive
142    /// fallback used when the default config cannot compile (should never
143    /// happen because defaults carry no user regex).
144    fn empty_failclosed() -> Self {
145        Self {
146            enabled: true,
147            allowed_domains: Vec::new(),
148            blocked_domains: Vec::new(),
149            allowed_verbs: HashSet::new(),
150            credential_detection: true,
151            extra_patterns: Vec::new(),
152        }
153    }
154
155    /// Build a guard with explicit configuration.
156    pub fn with_config(config: BrowserAutomationConfig) -> Result<Self, BrowserAutomationError> {
157        let mut extra_patterns = Vec::with_capacity(config.extra_credential_patterns.len());
158        for pat in &config.extra_credential_patterns {
159            let re = Regex::new(pat).map_err(|e| BrowserAutomationError::InvalidPattern {
160                pattern: pat.clone(),
161                source: e,
162            })?;
163            extra_patterns.push(re);
164        }
165        let allowed_verbs: HashSet<String> = config
166            .allowed_verbs
167            .into_iter()
168            .map(|v| v.to_ascii_lowercase())
169            .collect();
170        Ok(Self {
171            enabled: config.enabled,
172            allowed_domains: config.allowed_domains,
173            blocked_domains: config.blocked_domains,
174            allowed_verbs,
175            credential_detection: config.credential_detection,
176            extra_patterns,
177        })
178    }
179
180    /// Evaluate a navigation verb against the blocked/allowed domain
181    /// sets.  Returns `Verdict::Deny` when the target is blocked or
182    /// outside a non-empty allowlist.
183    fn check_navigation(&self, target: Option<&str>) -> Verdict {
184        let empty_allow = self.allowed_domains.is_empty();
185        let empty_block = self.blocked_domains.is_empty();
186        if empty_allow && empty_block {
187            return Verdict::Allow;
188        }
189        let url = match target {
190            Some(u) if !u.trim().is_empty() => u,
191            // Missing target with a configured allowlist is fail-closed:
192            // we cannot attest the nav host, so deny.
193            _ if !empty_allow => return Verdict::Deny,
194            _ => return Verdict::Allow,
195        };
196        let host = match extract_host(url) {
197            Some(h) => h,
198            None if !empty_allow => return Verdict::Deny,
199            None => return Verdict::Allow,
200        };
201        if self
202            .blocked_domains
203            .iter()
204            .any(|pat| matches_domain(pat, &host))
205        {
206            return Verdict::Deny;
207        }
208        if !empty_allow
209            && !self
210                .allowed_domains
211                .iter()
212                .any(|pat| matches_domain(pat, &host))
213        {
214            return Verdict::Deny;
215        }
216        Verdict::Allow
217    }
218
219    /// Check whether `text` looks like a credential / secret.  Runs both
220    /// built-in detectors and any extra regexes supplied via config.
221    fn looks_like_credential(&self, text: &str) -> bool {
222        if text.trim().is_empty() {
223            return false;
224        }
225        for re in builtin_credential_patterns() {
226            if re.is_match(text) {
227                return true;
228            }
229        }
230        for re in &self.extra_patterns {
231            if re.is_match(text) {
232                return true;
233            }
234        }
235        false
236    }
237}
238
239impl Default for BrowserAutomationGuard {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl Guard for BrowserAutomationGuard {
246    fn name(&self) -> &str {
247        "browser-automation"
248    }
249
250    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
251        if !self.enabled {
252            return Ok(GuardDecision::allow());
253        }
254
255        let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
256            Ok(action) => action,
257            Err(_) => return Ok(GuardDecision::deny(Vec::new())),
258        };
259        let (verb, target) = match action {
260            ToolAction::BrowserAction { verb, target } => (verb, target),
261            _ => return Ok(GuardDecision::allow()),
262        };
263
264        let verb_lower = verb.to_ascii_lowercase();
265
266        // 1. Verb allowlist.
267        if !self.allowed_verbs.is_empty() && !self.allowed_verbs.contains(&verb_lower) {
268            return Ok(GuardDecision::deny(Vec::new()));
269        }
270
271        // 2. Navigation domain gating.
272        if is_navigation_verb(&verb_lower) {
273            let target_ref = target.as_deref().filter(|s| !is_selector_like(s));
274            return Ok(GuardDecision::from_verdict(
275                self.check_navigation(target_ref),
276            ));
277        }
278
279        // 3. Credential detection on type/input verbs.
280        if self.credential_detection && is_type_verb(&verb_lower) {
281            if let Some(text) = extract_type_text(&ctx.request.arguments) {
282                if self.looks_like_credential(&text) {
283                    return Ok(GuardDecision::deny(Vec::new()));
284                }
285            }
286        }
287
288        Ok(GuardDecision::allow())
289    }
290}
291
292/// Return true when `s` looks like a CSS selector / xpath / anchor rather
293/// than a navigation URL.
294fn is_selector_like(s: &str) -> bool {
295    let trimmed = s.trim();
296    trimmed.starts_with('#')
297        || trimmed.starts_with('.')
298        || trimmed.starts_with('[')
299        || trimmed.starts_with('/') && !trimmed.starts_with("//")
300        || trimmed.starts_with("xpath=")
301}
302
303fn is_navigation_verb(verb: &str) -> bool {
304    matches!(verb, "navigate" | "goto" | "open" | "load" | "browse")
305}
306
307fn is_type_verb(verb: &str) -> bool {
308    matches!(
309        verb,
310        "type" | "input" | "fill" | "browser_type" | "type_text" | "enter_text" | "send_keys"
311    )
312}
313
314/// Extract the string the agent wants to type.  Looks at common argument
315/// names: `text`, `value`, `content`, `input`, `keys`.
316fn extract_type_text(arguments: &Value) -> Option<String> {
317    for key in ["text", "value", "content", "input", "keys"] {
318        if let Some(v) = arguments.get(key).and_then(|v| v.as_str()) {
319            if !v.is_empty() {
320                return Some(v.to_string());
321            }
322        }
323    }
324    None
325}
326
327/// Match a domain host against a pattern.  Supports exact match and
328/// `*.suffix` wildcards.
329fn matches_domain(pattern: &str, host: &str) -> bool {
330    let pattern = pattern.trim().to_ascii_lowercase();
331    let host = host.trim().to_ascii_lowercase();
332    if pattern.is_empty() || host.is_empty() {
333        return false;
334    }
335    if let Some(suffix) = pattern.strip_prefix("*.") {
336        return host == suffix || host.ends_with(&format!(".{suffix}"));
337    }
338    pattern == host
339}
340
341/// Extract the host portion of a URL.
342fn extract_host(url: &str) -> Option<String> {
343    let url = url.trim();
344    if url.is_empty() {
345        return None;
346    }
347    if url.starts_with('#') || url.starts_with('.') || url.starts_with('[') {
348        return None;
349    }
350    let lowered = url.to_ascii_lowercase();
351    if lowered.starts_with("data:")
352        || lowered.starts_with("javascript:")
353        || lowered.starts_with("about:")
354        || lowered.starts_with("file:")
355    {
356        return None;
357    }
358    let rest = if lowered.starts_with("https://") {
359        &url["https://".len()..]
360    } else if lowered.starts_with("http://") {
361        &url["http://".len()..]
362    } else if let Some(rest) = url.strip_prefix("//") {
363        rest
364    } else {
365        url
366    };
367    let host_with_port = rest.split(['/', '?', '#']).next().unwrap_or(rest);
368    let host_without_userinfo = host_with_port
369        .rsplit_once('@')
370        .map(|(_, host)| host)
371        .unwrap_or(host_with_port);
372    let host = if let Some(bracketed) = host_without_userinfo.strip_prefix('[') {
373        let (host, remainder) = bracketed.split_once(']')?;
374        if !remainder.is_empty() && !remainder.starts_with(':') {
375            return None;
376        }
377        host
378    } else {
379        host_without_userinfo
380            .rsplit_once(':')
381            .map(|(h, _)| h)
382            .unwrap_or(host_without_userinfo)
383    }
384    .trim_matches(|c: char| c == '/' || c == '.');
385    if host.is_empty() {
386        return None;
387    }
388    Some(host.to_ascii_lowercase())
389}
390
391/// Compiled once per process.  Returns regexes that match common
392/// credential / secret shapes appearing in Type action text.
393fn builtin_credential_patterns() -> &'static [Regex] {
394    static PATS: OnceLock<Vec<Regex>> = OnceLock::new();
395    PATS.get_or_init(|| {
396        let sources = [
397            // AWS access key ID.
398            r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
399            // GitHub personal access tokens.
400            r"\bgh[pousr]_[A-Za-z0-9]{36,}\b",
401            // Slack bot/user tokens.
402            r"\bxox[abopsr]-[A-Za-z0-9-]{10,}\b",
403            // JWT shape.
404            r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b",
405            // PEM private keys.
406            r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----",
407            // Generic `password = ...` / `token = ...` assignment shapes.
408            r"(?i)\b(?:password|passwd|pwd|token|api[_-]?key|secret|bearer)\s*[:=]\s*\S{6,}",
409            // OpenAI-style API key prefix.
410            r"\bsk-[A-Za-z0-9]{20,}\b",
411            // Stripe secret key prefix.
412            r"\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b",
413        ];
414        sources
415            .iter()
416            .filter_map(|s| match Regex::new(s) {
417                Ok(re) => Some(re),
418                Err(err) => {
419                    tracing::error!(error = %err, source = %s, "browser-automation: builtin credential regex failed");
420                    None
421                }
422            })
423            .collect()
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn extract_host_basic() {
433        assert_eq!(
434            extract_host("https://example.com/x"),
435            Some("example.com".into())
436        );
437        assert_eq!(
438            extract_host("HTTPS://Blocked.Example/x"),
439            Some("blocked.example".into())
440        );
441        assert_eq!(
442            extract_host("https://user:pass@blocked.example:8443/path"),
443            Some("blocked.example".into())
444        );
445        assert_eq!(
446            extract_host("https://user@[fd00:ec2::254]:8443/path"),
447            Some("fd00:ec2::254".into())
448        );
449        assert_eq!(
450            extract_host("//blocked.example/path"),
451            Some("blocked.example".into())
452        );
453        assert_eq!(
454            extract_host("https://blocked.example?redir=1"),
455            Some("blocked.example".into())
456        );
457        assert_eq!(
458            extract_host("https://blocked.example#anchor"),
459            Some("blocked.example".into())
460        );
461        assert_eq!(extract_host("#submit"), None);
462        assert_eq!(extract_host("data:text/plain,hi"), None);
463    }
464
465    #[test]
466    fn matches_domain_wildcard() {
467        assert!(matches_domain("*.example.com", "api.example.com"));
468        assert!(!matches_domain("*.example.com", "example.org"));
469        assert!(matches_domain("example.com", "example.com"));
470    }
471
472    #[test]
473    fn builtin_detects_common_tokens() {
474        let guard = BrowserAutomationGuard::new();
475        assert!(guard.looks_like_credential("AKIAABCDEFGHIJKLMNOP"));
476        assert!(guard.looks_like_credential("password=hunter2345"));
477        assert!(guard.looks_like_credential("sk-0123456789abcdef01234567"));
478        assert!(!guard.looks_like_credential("hello world"));
479        assert!(!guard.looks_like_credential(""));
480    }
481
482    #[test]
483    fn is_selector_like_classifies() {
484        assert!(is_selector_like("#submit"));
485        assert!(is_selector_like(".login"));
486        assert!(is_selector_like("[data-id=1]"));
487        assert!(!is_selector_like("https://example.com/x"));
488        assert!(!is_selector_like("//example.com"));
489    }
490
491    #[test]
492    fn check_navigation_blocks_scheme_relative_urls() {
493        let guard = BrowserAutomationGuard::with_config(BrowserAutomationConfig {
494            blocked_domains: vec!["blocked.example".into()],
495            ..BrowserAutomationConfig::default()
496        })
497        .expect("default browser automation config should compile");
498
499        assert_eq!(
500            guard.check_navigation(Some("//blocked.example/path")),
501            Verdict::Deny
502        );
503    }
504
505    #[test]
506    fn check_navigation_blocks_urls_with_userinfo() {
507        let guard = BrowserAutomationGuard::with_config(BrowserAutomationConfig {
508            blocked_domains: vec!["blocked.example".into()],
509            ..BrowserAutomationConfig::default()
510        })
511        .expect("default browser automation config should compile");
512
513        assert_eq!(
514            guard.check_navigation(Some("https://user@blocked.example/path")),
515            Verdict::Deny
516        );
517    }
518
519    #[test]
520    fn check_navigation_blocks_bracketed_ipv6_hosts() {
521        let guard = BrowserAutomationGuard::with_config(BrowserAutomationConfig {
522            blocked_domains: vec!["fd00:ec2::254".into()],
523            ..BrowserAutomationConfig::default()
524        })
525        .expect("default browser automation config should compile");
526
527        assert_eq!(
528            guard.check_navigation(Some("https://[fd00:ec2::254]/latest")),
529            Verdict::Deny
530        );
531    }
532
533    #[test]
534    fn check_navigation_blocks_mixed_case_scheme_urls() {
535        let guard = BrowserAutomationGuard::with_config(BrowserAutomationConfig {
536            blocked_domains: vec!["blocked.example".into()],
537            ..BrowserAutomationConfig::default()
538        })
539        .expect("default browser automation config should compile");
540
541        assert_eq!(
542            guard.check_navigation(Some("HTTPS://blocked.example/path")),
543            Verdict::Deny
544        );
545    }
546
547    #[test]
548    fn check_navigation_blocks_query_and_fragment_only_urls() {
549        let guard = BrowserAutomationGuard::with_config(BrowserAutomationConfig {
550            blocked_domains: vec!["blocked.example".into()],
551            ..BrowserAutomationConfig::default()
552        })
553        .expect("default browser automation config should compile");
554
555        assert_eq!(
556            guard.check_navigation(Some("https://blocked.example?redir=1")),
557            Verdict::Deny
558        );
559        assert_eq!(
560            guard.check_navigation(Some("https://blocked.example#anchor")),
561            Verdict::Deny
562        );
563    }
564}