1use std::collections::HashSet;
34
35use serde::{Deserialize, Serialize};
36
37use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
38
39use crate::action::{extract_action_checked, ToolAction};
40use crate::external::TokenBucket;
41
42pub fn default_allowed_action_types() -> Vec<String> {
47 vec![
48 "remote.session.connect".to_string(),
49 "remote.session.disconnect".to_string(),
50 "remote.session.reconnect".to_string(),
51 "input.inject".to_string(),
52 "remote.clipboard".to_string(),
53 "remote.file_transfer".to_string(),
54 "remote.audio".to_string(),
55 "remote.drive_mapping".to_string(),
56 "remote.printing".to_string(),
57 "remote.session_share".to_string(),
58 ]
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum EnforcementMode {
65 Observe,
67 #[default]
69 Guardrail,
70 FailClosed,
72}
73
74#[derive(Clone, Debug, Deserialize, Serialize)]
76#[serde(deny_unknown_fields)]
77pub struct ComputerUseConfig {
78 #[serde(default = "default_true")]
81 pub enabled: bool,
82 #[serde(default = "default_allowed_action_types")]
84 pub allowed_action_types: Vec<String>,
85 #[serde(default)]
87 pub mode: EnforcementMode,
88 #[serde(default)]
91 pub blocked_domains: Vec<String>,
92 #[serde(default)]
97 pub allowed_domains: Vec<String>,
98 #[serde(default)]
101 pub screenshot_rate_per_second: Option<f64>,
102 #[serde(default)]
105 pub screenshot_burst: Option<u32>,
106}
107
108fn default_true() -> bool {
109 true
110}
111
112impl Default for ComputerUseConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 allowed_action_types: default_allowed_action_types(),
117 mode: EnforcementMode::Guardrail,
118 blocked_domains: Vec::new(),
119 allowed_domains: Vec::new(),
120 screenshot_rate_per_second: None,
121 screenshot_burst: None,
122 }
123 }
124}
125
126pub struct ComputerUseGuard {
130 enabled: bool,
131 mode: EnforcementMode,
132 allowed_actions: HashSet<String>,
133 blocked_domains: Vec<String>,
134 allowed_domains: Vec<String>,
135 screenshot_bucket: Option<TokenBucket>,
136}
137
138impl ComputerUseGuard {
139 pub fn new() -> Self {
141 Self::with_config(ComputerUseConfig::default())
142 }
143
144 pub fn with_config(config: ComputerUseConfig) -> Self {
146 let allowed_actions: HashSet<String> = config.allowed_action_types.into_iter().collect();
147 let screenshot_bucket = match config.screenshot_rate_per_second {
148 Some(rate) if rate > 0.0 && rate.is_finite() => {
149 let burst = config.screenshot_burst.unwrap_or(5).max(1);
150 Some(TokenBucket::new(rate, burst))
151 }
152 _ => None,
153 };
154 Self {
155 enabled: config.enabled,
156 mode: config.mode,
157 allowed_actions,
158 blocked_domains: config.blocked_domains,
159 allowed_domains: config.allowed_domains,
160 screenshot_bucket,
161 }
162 }
163
164 fn is_screenshot_verb(verb: &str) -> bool {
167 let v = verb.to_ascii_lowercase();
168 matches!(
169 v.as_str(),
170 "screenshot"
171 | "screen_capture"
172 | "screen_shot"
173 | "capture"
174 | "capture_screen"
175 | "browser_screenshot"
176 )
177 }
178
179 fn extract_cua_action_type<'a>(
186 tool_name: &'a str,
187 arguments: &'a serde_json::Value,
188 ) -> Option<String> {
189 if tool_name.starts_with("remote.") || tool_name.starts_with("input.") {
190 return Some(tool_name.to_string());
191 }
192 for key in ["action_type", "actionType", "custom_type", "customType"] {
193 if let Some(value) = arguments.get(key).and_then(|v| v.as_str()) {
194 if value.starts_with("remote.") || value.starts_with("input.") {
195 return Some(value.to_string());
196 }
197 }
198 }
199 None
200 }
201
202 fn apply_mode(&self, in_allowlist: bool) -> Verdict {
204 match (self.mode, in_allowlist) {
205 (EnforcementMode::Observe, _) => Verdict::Allow,
206 (EnforcementMode::Guardrail, _) => Verdict::Allow,
207 (EnforcementMode::FailClosed, true) => Verdict::Allow,
208 (EnforcementMode::FailClosed, false) => Verdict::Deny,
209 }
210 }
211
212 fn check_navigation(&self, target: &str) -> Verdict {
214 if self.blocked_domains.is_empty() && self.allowed_domains.is_empty() {
217 return Verdict::Allow;
218 }
219 let host = match extract_host(target) {
220 Some(host) => host,
221 None => {
222 return Verdict::Allow;
226 }
227 };
228 let blocked = self
229 .blocked_domains
230 .iter()
231 .any(|pat| matches_domain(pat, &host));
232 if blocked {
233 return match self.mode {
234 EnforcementMode::Observe => Verdict::Allow,
235 EnforcementMode::Guardrail | EnforcementMode::FailClosed => Verdict::Deny,
236 };
237 }
238 if !self.allowed_domains.is_empty() {
239 let allowed = self
240 .allowed_domains
241 .iter()
242 .any(|pat| matches_domain(pat, &host));
243 if !allowed {
244 return match self.mode {
245 EnforcementMode::Observe | EnforcementMode::Guardrail => Verdict::Allow,
246 EnforcementMode::FailClosed => Verdict::Deny,
247 };
248 }
249 }
250 Verdict::Allow
251 }
252}
253
254impl Default for ComputerUseGuard {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260impl Guard for ComputerUseGuard {
261 fn name(&self) -> &str {
262 "computer-use"
263 }
264
265 fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
266 if !self.enabled {
267 return Ok(GuardDecision::allow());
268 }
269
270 if let Some(action_type) =
272 Self::extract_cua_action_type(&ctx.request.tool_name, &ctx.request.arguments)
273 {
274 let in_allowlist = self.allowed_actions.contains(&action_type);
275 return Ok(GuardDecision::from_verdict(self.apply_mode(in_allowlist)));
276 }
277
278 let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
280 Ok(action) => action,
281 Err(_) => return Ok(GuardDecision::deny(Vec::new())),
282 };
283 if let ToolAction::BrowserAction { verb, target } = &action {
284 if Self::is_screenshot_verb(verb) {
286 if let Some(bucket) = &self.screenshot_bucket {
287 if !bucket.try_acquire() {
288 return Ok(GuardDecision::from_verdict(match self.mode {
289 EnforcementMode::Observe => Verdict::Allow,
290 EnforcementMode::Guardrail | EnforcementMode::FailClosed => {
291 Verdict::Deny
292 }
293 }));
294 }
295 }
296 return Ok(GuardDecision::allow());
297 }
298
299 if matches!(
301 verb.to_ascii_lowercase().as_str(),
302 "navigate" | "goto" | "open"
303 ) {
304 if let Some(url) = target {
305 return Ok(GuardDecision::from_verdict(self.check_navigation(url)));
306 }
307 }
308 }
309
310 Ok(GuardDecision::allow())
312 }
313}
314
315fn matches_domain(pattern: &str, host: &str) -> bool {
318 let pattern = pattern.trim().to_ascii_lowercase();
319 let host = host.trim().to_ascii_lowercase();
320 if pattern.is_empty() || host.is_empty() {
321 return false;
322 }
323 if let Some(suffix) = pattern.strip_prefix("*.") {
324 return host == suffix || host.ends_with(&format!(".{suffix}"));
325 }
326 pattern == host
327}
328
329fn extract_host(url: &str) -> Option<String> {
332 let url = url.trim();
333 if url.is_empty() {
334 return None;
335 }
336 if url.starts_with('#') || url.starts_with('.') || url.starts_with('[') {
338 return None;
339 }
340 let lowered = url.to_ascii_lowercase();
342 if lowered.starts_with("data:")
343 || lowered.starts_with("javascript:")
344 || lowered.starts_with("about:")
345 || lowered.starts_with("file:")
346 {
347 return None;
348 }
349 let rest = if lowered.starts_with("https://") {
350 &url["https://".len()..]
351 } else if lowered.starts_with("http://") {
352 &url["http://".len()..]
353 } else if let Some(rest) = url.strip_prefix("//") {
354 rest
355 } else {
356 url
357 };
358 let host_with_port = rest.split(['/', '?', '#']).next().unwrap_or(rest);
359 let host_without_userinfo = host_with_port
360 .rsplit_once('@')
361 .map(|(_, host)| host)
362 .unwrap_or(host_with_port);
363 let host = if let Some(bracketed) = host_without_userinfo.strip_prefix('[') {
364 let (host, remainder) = bracketed.split_once(']')?;
365 if !remainder.is_empty() && !remainder.starts_with(':') {
366 return None;
367 }
368 host
369 } else {
370 host_without_userinfo
371 .rsplit_once(':')
372 .map(|(h, _)| h)
373 .unwrap_or(host_without_userinfo)
374 }
375 .trim_matches(|c: char| c == '/' || c == '.');
376 if host.is_empty() {
377 return None;
378 }
379 Some(host.to_ascii_lowercase())
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn matches_domain_exact_and_wildcard() {
388 assert!(matches_domain("example.com", "example.com"));
389 assert!(!matches_domain("example.com", "evil.com"));
390 assert!(matches_domain("*.example.com", "api.example.com"));
391 assert!(matches_domain("*.example.com", "example.com"));
392 assert!(!matches_domain("*.example.com", "example.org"));
393 }
394
395 #[test]
396 fn extract_host_handles_common_urls() {
397 assert_eq!(
398 extract_host("https://example.com/x"),
399 Some("example.com".into())
400 );
401 assert_eq!(
402 extract_host("HTTPS://169.254.169.254/latest"),
403 Some("169.254.169.254".into())
404 );
405 assert_eq!(
406 extract_host("https://user:pass@example.com:8443/x"),
407 Some("example.com".into())
408 );
409 assert_eq!(
410 extract_host("https://user@[fd00:ec2::254]:8443/x"),
411 Some("fd00:ec2::254".into())
412 );
413 assert_eq!(
414 extract_host("http://localhost:8080"),
415 Some("localhost".into())
416 );
417 assert_eq!(
418 extract_host("example.com:443/y"),
419 Some("example.com".into())
420 );
421 assert_eq!(
422 extract_host("//169.254.169.254/latest"),
423 Some("169.254.169.254".into())
424 );
425 assert_eq!(
426 extract_host("https://blocked.example?redir=1"),
427 Some("blocked.example".into())
428 );
429 assert_eq!(
430 extract_host("https://blocked.example#anchor"),
431 Some("blocked.example".into())
432 );
433 assert_eq!(extract_host("#submit"), None);
434 assert_eq!(extract_host("data:text/plain,hi"), None);
435 }
436
437 #[test]
438 fn check_navigation_blocks_scheme_relative_urls() {
439 let guard = ComputerUseGuard::with_config(ComputerUseConfig {
440 mode: EnforcementMode::FailClosed,
441 blocked_domains: vec!["169.254.169.254".into()],
442 ..ComputerUseConfig::default()
443 });
444
445 assert_eq!(
446 guard.check_navigation("//169.254.169.254/latest"),
447 Verdict::Deny
448 );
449 }
450
451 #[test]
452 fn check_navigation_blocks_urls_with_userinfo() {
453 let guard = ComputerUseGuard::with_config(ComputerUseConfig {
454 mode: EnforcementMode::FailClosed,
455 blocked_domains: vec!["blocked.example".into()],
456 ..ComputerUseConfig::default()
457 });
458
459 assert_eq!(
460 guard.check_navigation("https://user@blocked.example/path"),
461 Verdict::Deny
462 );
463 }
464
465 #[test]
466 fn check_navigation_blocks_bracketed_ipv6_hosts() {
467 let guard = ComputerUseGuard::with_config(ComputerUseConfig {
468 mode: EnforcementMode::FailClosed,
469 blocked_domains: vec!["fd00:ec2::254".into()],
470 ..ComputerUseConfig::default()
471 });
472
473 assert_eq!(
474 guard.check_navigation("https://[fd00:ec2::254]/latest"),
475 Verdict::Deny
476 );
477 }
478
479 #[test]
480 fn check_navigation_blocks_query_and_fragment_only_urls() {
481 let guard = ComputerUseGuard::with_config(ComputerUseConfig {
482 mode: EnforcementMode::FailClosed,
483 blocked_domains: vec!["blocked.example".into()],
484 ..ComputerUseConfig::default()
485 });
486
487 assert_eq!(
488 guard.check_navigation("https://blocked.example?redir=1"),
489 Verdict::Deny
490 );
491 assert_eq!(
492 guard.check_navigation("https://blocked.example#anchor"),
493 Verdict::Deny
494 );
495 }
496
497 #[test]
498 fn check_navigation_blocks_mixed_case_scheme_urls() {
499 let guard = ComputerUseGuard::with_config(ComputerUseConfig {
500 mode: EnforcementMode::FailClosed,
501 blocked_domains: vec!["169.254.169.254".into()],
502 ..ComputerUseConfig::default()
503 });
504
505 assert_eq!(
506 guard.check_navigation("HTTPS://169.254.169.254/latest"),
507 Verdict::Deny
508 );
509 }
510
511 #[test]
512 fn is_screenshot_verb_matches_common_names() {
513 assert!(ComputerUseGuard::is_screenshot_verb("screenshot"));
514 assert!(ComputerUseGuard::is_screenshot_verb("capture_screen"));
515 assert!(!ComputerUseGuard::is_screenshot_verb("click"));
516 }
517
518 #[test]
519 fn extract_cua_action_type_reads_args() {
520 let args = serde_json::json!({"action_type": "remote.clipboard"});
521 assert_eq!(
522 ComputerUseGuard::extract_cua_action_type("unknown", &args),
523 Some("remote.clipboard".to_string())
524 );
525 }
526}