Skip to main content

chio_guards/
computer_use.rs

1//! ComputerUseGuard - coarse gate for Computer Use Agent (CUA) actions.
2//!
3//! Implements Chio's synchronous [`chio_kernel::Guard`] trait.
4//!
5//! The guard is a coarse-grained allowlist for CUA action types.  It
6//! recognises three surfaces that arrive on the kernel:
7//!
8//! 1. **Remote-session and side-channel actions** - tool names or
9//!    `action_type`/`custom_type` arguments that start with `remote.` or
10//!    `input.` (e.g., `remote.clipboard`, `input.inject`).  The action-type
11//!    string is matched against a configurable allowlist.
12//! 2. **[`ToolAction::BrowserAction`]** - browser navigation verbs.  The
13//!    guard denies navigation to configured blocked domains.
14//! 3. **Screenshot actions** (subset of [`ToolAction::BrowserAction`] with
15//!    a `screenshot`-family verb) - rate-limited via a token bucket so a
16//!    runaway agent cannot drain the capture channel.
17//!
18//! Enforcement modes:
19//!
20//! | Mode         | Behavior                                              |
21//! |--------------|-------------------------------------------------------|
22//! | [`EnforcementMode::Observe`]     | Always allow; logs every decision |
23//! | [`EnforcementMode::Guardrail`]   | Allow if in allowlist, warn otherwise (default) |
24//! | [`EnforcementMode::FailClosed`]  | Allow if in allowlist, deny otherwise |
25//!
26//! Fail-closed semantics:
27//!
28//! - [`ToolAction::Unknown`] / non-CUA actions → [`Verdict::Allow`];
29//! - invalid configuration → best-effort fallback to defaults at build
30//!   time (never panics);
31//! - token-bucket mutex poisoning → treated as no-tokens (deny).
32
33use 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
42/// Default allowlist of CUA action-type strings.
43///
44/// Covers the common remote-session and input action taxonomy so existing
45/// policies continue to work without translation.
46pub 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/// Enforcement modes for [`ComputerUseGuard`].
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum EnforcementMode {
65    /// Always allow, regardless of allowlist membership.
66    Observe,
67    /// Allow if in allowlist; allow-with-warning otherwise.
68    #[default]
69    Guardrail,
70    /// Allow if in allowlist; deny otherwise (fail-closed).
71    FailClosed,
72}
73
74/// Configuration for [`ComputerUseGuard`].
75#[derive(Clone, Debug, Deserialize, Serialize)]
76#[serde(deny_unknown_fields)]
77pub struct ComputerUseConfig {
78    /// Enable/disable the guard.  When `false`, [`Guard::evaluate`] always
79    /// returns `Allow`.
80    #[serde(default = "default_true")]
81    pub enabled: bool,
82    /// Allowed CUA action-type strings (for `remote.*` / `input.*` flows).
83    #[serde(default = "default_allowed_action_types")]
84    pub allowed_action_types: Vec<String>,
85    /// Enforcement mode.
86    #[serde(default)]
87    pub mode: EnforcementMode,
88    /// Domain patterns (exact host match or `*.suffix` wildcard) that are
89    /// blocked for browser navigation.
90    #[serde(default)]
91    pub blocked_domains: Vec<String>,
92    /// Optional allowlist of navigation hosts.  When non-empty, navigation
93    /// to a host outside the allowlist is treated the same as a blocked
94    /// domain in `FailClosed` mode, warned in `Guardrail`, ignored in
95    /// `Observe`.
96    #[serde(default)]
97    pub allowed_domains: Vec<String>,
98    /// Maximum screenshots per second (token-bucket refill rate).  `None`
99    /// disables rate limiting.
100    #[serde(default)]
101    pub screenshot_rate_per_second: Option<f64>,
102    /// Token-bucket burst capacity for screenshot rate limiting.  Defaults
103    /// to `5` when [`Self::screenshot_rate_per_second`] is set.
104    #[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
126/// Coarse gate for CUA actions.
127///
128/// See [module docs](self) for the policy surface.
129pub 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    /// Build a guard with default configuration.
140    pub fn new() -> Self {
141        Self::with_config(ComputerUseConfig::default())
142    }
143
144    /// Build a guard with an explicit configuration.
145    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    /// Returns `true` if the verb indicates a screenshot/screen-capture
165    /// browser action.
166    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    /// Extract the CUA `action_type` string from a tool call, if any.
180    ///
181    /// Checks (in priority order):
182    /// 1. `tool_name` itself if it starts with `remote.` or `input.`;
183    /// 2. the `action_type` / `actionType` argument;
184    /// 3. the `custom_type` / `customType` argument.
185    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    /// Apply the configured enforcement mode to an allowlist decision.
203    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    /// Check browser navigation against the blocked/allowed domain sets.
213    fn check_navigation(&self, target: &str) -> Verdict {
214        // Only apply navigation gating when either list has content; the
215        // module docs call this out as opt-in.
216        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                // Opaque navigation targets (selectors, data URIs) are
223                // allowed here - finer checks belong to
224                // `BrowserNavigationGuard`.
225                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        // 1. Direct CUA action-type dispatch (remote.*, input.*).
271        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        // 2. BrowserAction: navigation domain checks + screenshot rate limit.
279        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            // Screenshot rate-limit.
285            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            // Navigation domain check.
300            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        // 3. Non-CUA actions pass through.
311        Ok(GuardDecision::allow())
312    }
313}
314
315/// Match a domain host against a pattern.  Supports exact match and
316/// `*.suffix` wildcard patterns (same semantics as Chio's egress allowlist).
317fn 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
329/// Extract the host portion of a URL.  Returns `None` for opaque targets
330/// like CSS selectors, data URIs, or empty strings.
331fn extract_host(url: &str) -> Option<String> {
332    let url = url.trim();
333    if url.is_empty() {
334        return None;
335    }
336    // Reject obvious non-URL targets used by browser click/type actions.
337    if url.starts_with('#') || url.starts_with('.') || url.starts_with('[') {
338        return None;
339    }
340    // Reject data / javascript / about URIs - no network host.
341    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}