1use 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
43pub 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#[derive(Debug, thiserror::Error)]
66pub enum BrowserAutomationError {
67 #[error("invalid credential pattern `{pattern}`: {source}")]
69 InvalidPattern {
70 pattern: String,
71 #[source]
72 source: regex::Error,
73 },
74}
75
76#[derive(Clone, Debug, Deserialize, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct BrowserAutomationConfig {
80 #[serde(default = "default_true")]
82 pub enabled: bool,
83 #[serde(default)]
87 pub allowed_domains: Vec<String>,
88 #[serde(default)]
90 pub blocked_domains: Vec<String>,
91 #[serde(default = "default_allowed_verbs")]
93 pub allowed_verbs: Vec<String>,
94 #[serde(default = "default_true")]
97 pub credential_detection: bool,
98 #[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
121pub 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 pub fn new() -> Self {
135 match Self::with_config(BrowserAutomationConfig::default()) {
136 Ok(g) => g,
137 Err(_) => Self::empty_failclosed(),
138 }
139 }
140
141 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 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 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 _ 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 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 if !self.allowed_verbs.is_empty() && !self.allowed_verbs.contains(&verb_lower) {
268 return Ok(GuardDecision::deny(Vec::new()));
269 }
270
271 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 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
292fn 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
314fn 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
327fn 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
341fn 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
391fn builtin_credential_patterns() -> &'static [Regex] {
394 static PATS: OnceLock<Vec<Regex>> = OnceLock::new();
395 PATS.get_or_init(|| {
396 let sources = [
397 r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
399 r"\bgh[pousr]_[A-Za-z0-9]{36,}\b",
401 r"\bxox[abopsr]-[A-Za-z0-9-]{10,}\b",
403 r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b",
405 r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----",
407 r"(?i)\b(?:password|passwd|pwd|token|api[_-]?key|secret|bearer)\s*[:=]\s*\S{6,}",
409 r"\bsk-[A-Za-z0-9]{20,}\b",
411 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}