Skip to main content

chio_guards/
remote_desktop.rs

1//! RemoteDesktopSideChannelGuard - per-channel enable/disable with
2//! transfer-size limits for remote desktop / RDP / VNC style sessions.
3//!
4//! Roadmap phase 5.3.  Ported from ClawdStrike's
5//! `guards/remote_desktop_side_channel.rs` and adapted to Chio's
6//! synchronous [`chio_kernel::Guard`] trait.
7//!
8//! Handles six named side channels, each with an independent toggle:
9//!
10//! | Channel        | Action type              | Config field              |
11//! |----------------|--------------------------|---------------------------|
12//! | Clipboard      | `remote.clipboard`       | `clipboard_enabled`       |
13//! | File transfer  | `remote.file_transfer`   | `file_transfer_enabled`   |
14//! | Session share  | `remote.session_share`   | `session_share_enabled`   |
15//! | Audio          | `remote.audio`           | `audio_enabled`           |
16//! | Drive mapping  | `remote.drive_mapping`   | `drive_mapping_enabled`   |
17//! | Printing       | `remote.printing`        | `printing_enabled`        |
18//!
19//! Additional controls:
20//!
21//! - `max_transfer_size_bytes`: when set, `remote.file_transfer` actions
22//!   must include a `transfer_size` / `transferSize` `u64` argument.
23//!   Missing, non-integer, or oversized values are denied.
24//! - **Unknown `remote.*` channels are denied** - the default branch is
25//!   fail-closed so new side channels are not silently permitted.
26//!
27//! Session-lifecycle actions (`remote.session.connect`,
28//! `remote.session.disconnect`, `remote.session.reconnect`) are **not**
29//! claimed by this guard; they are the job of [`crate::ComputerUseGuard`]
30//! at the coarse layer.
31
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
36
37/// Configuration for [`RemoteDesktopSideChannelGuard`].
38#[derive(Clone, Debug, Deserialize, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct RemoteDesktopSideChannelConfig {
41    /// Enable/disable the guard entirely.
42    #[serde(default = "default_true")]
43    pub enabled: bool,
44    /// Allow clipboard operations.
45    #[serde(default = "default_true")]
46    pub clipboard_enabled: bool,
47    /// Allow file transfer operations.
48    #[serde(default = "default_true")]
49    pub file_transfer_enabled: bool,
50    /// Allow session sharing.
51    #[serde(default = "default_true")]
52    pub session_share_enabled: bool,
53    /// Allow remote audio channel.
54    #[serde(default = "default_true")]
55    pub audio_enabled: bool,
56    /// Allow remote drive mapping.
57    #[serde(default = "default_true")]
58    pub drive_mapping_enabled: bool,
59    /// Allow remote printing.
60    #[serde(default = "default_true")]
61    pub printing_enabled: bool,
62    /// Maximum file-transfer size in bytes.  `None` disables the check.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub max_transfer_size_bytes: Option<u64>,
65}
66
67fn default_true() -> bool {
68    true
69}
70
71impl Default for RemoteDesktopSideChannelConfig {
72    fn default() -> Self {
73        Self {
74            enabled: true,
75            clipboard_enabled: true,
76            file_transfer_enabled: true,
77            session_share_enabled: true,
78            audio_enabled: true,
79            drive_mapping_enabled: true,
80            printing_enabled: true,
81            max_transfer_size_bytes: None,
82        }
83    }
84}
85
86/// Guard that enforces per-channel toggles and transfer-size limits for
87/// remote desktop side channels.
88pub struct RemoteDesktopSideChannelGuard {
89    config: RemoteDesktopSideChannelConfig,
90}
91
92impl RemoteDesktopSideChannelGuard {
93    /// Build a guard with default configuration (all channels enabled,
94    /// no transfer limit).
95    pub fn new() -> Self {
96        Self::with_config(RemoteDesktopSideChannelConfig::default())
97    }
98
99    /// Build a guard with explicit configuration.
100    pub fn with_config(config: RemoteDesktopSideChannelConfig) -> Self {
101        Self { config }
102    }
103
104    /// Return the side-channel `remote.*` action type this call targets,
105    /// if any.  Checks `tool_name` first, then falls back to
106    /// `action_type` / `custom_type` arguments.
107    fn channel_action_type(tool_name: &str, arguments: &Value) -> Option<String> {
108        if is_side_channel(tool_name) {
109            return Some(tool_name.to_string());
110        }
111        for key in ["action_type", "actionType", "custom_type", "customType"] {
112            if let Some(v) = arguments.get(key).and_then(|v| v.as_str()) {
113                if is_side_channel(v) {
114                    return Some(v.to_string());
115                }
116            }
117        }
118        None
119    }
120
121    /// Parse a `transfer_size` / `transferSize` argument into a byte
122    /// count.  Returns:
123    ///
124    /// - `Ok(Some(u64))` when a valid value is present;
125    /// - `Ok(None)` when the field is absent;
126    /// - `Err(())` when the field is present but not a `u64`.
127    #[allow(clippy::result_unit_err)]
128    fn read_transfer_size(arguments: &Value) -> Result<Option<u64>, ()> {
129        let value = match arguments
130            .get("transfer_size")
131            .or_else(|| arguments.get("transferSize"))
132        {
133            Some(v) => v,
134            None => return Ok(None),
135        };
136        match value.as_u64() {
137            Some(n) => Ok(Some(n)),
138            None => Err(()),
139        }
140    }
141}
142
143impl Default for RemoteDesktopSideChannelGuard {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl Guard for RemoteDesktopSideChannelGuard {
150    fn name(&self) -> &str {
151        "remote-desktop-side-channel"
152    }
153
154    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
155        if !self.config.enabled {
156            return Ok(Verdict::Allow);
157        }
158
159        let channel =
160            match Self::channel_action_type(&ctx.request.tool_name, &ctx.request.arguments) {
161                Some(c) => c,
162                None => return Ok(Verdict::Allow),
163            };
164
165        match channel.as_str() {
166            "remote.clipboard" => Ok(if self.config.clipboard_enabled {
167                Verdict::Allow
168            } else {
169                Verdict::Deny
170            }),
171            "remote.file_transfer" => {
172                if !self.config.file_transfer_enabled {
173                    return Ok(Verdict::Deny);
174                }
175                if let Some(max) = self.config.max_transfer_size_bytes {
176                    match Self::read_transfer_size(&ctx.request.arguments) {
177                        Ok(Some(n)) => {
178                            if n > max {
179                                return Ok(Verdict::Deny);
180                            }
181                        }
182                        // Missing or non-integer transfer_size with a
183                        // configured max → fail-closed.
184                        Ok(None) | Err(()) => return Ok(Verdict::Deny),
185                    }
186                }
187                Ok(Verdict::Allow)
188            }
189            "remote.session_share" => Ok(if self.config.session_share_enabled {
190                Verdict::Allow
191            } else {
192                Verdict::Deny
193            }),
194            "remote.audio" => Ok(if self.config.audio_enabled {
195                Verdict::Allow
196            } else {
197                Verdict::Deny
198            }),
199            "remote.drive_mapping" => Ok(if self.config.drive_mapping_enabled {
200                Verdict::Allow
201            } else {
202                Verdict::Deny
203            }),
204            "remote.printing" => Ok(if self.config.printing_enabled {
205                Verdict::Allow
206            } else {
207                Verdict::Deny
208            }),
209            // Unknown `remote.*` channel → fail-closed.
210            _ => Ok(Verdict::Deny),
211        }
212    }
213}
214
215/// Return `true` when `s` is a `remote.*` side-channel action type
216/// (excluding the session-lifecycle trio owned by [`crate::ComputerUseGuard`]).
217fn is_side_channel(s: &str) -> bool {
218    if !s.starts_with("remote.") {
219        return false;
220    }
221    !matches!(
222        s,
223        "remote.session.connect" | "remote.session.disconnect" | "remote.session.reconnect"
224    )
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn is_side_channel_classifies_correctly() {
233        assert!(is_side_channel("remote.clipboard"));
234        assert!(is_side_channel("remote.file_transfer"));
235        assert!(is_side_channel("remote.webrtc")); // unknown but still `remote.*`
236        assert!(!is_side_channel("remote.session.connect"));
237        assert!(!is_side_channel("input.inject"));
238        assert!(!is_side_channel("filesystem"));
239    }
240
241    #[test]
242    fn read_transfer_size_variants() {
243        let ok = serde_json::json!({"transfer_size": 1024});
244        assert_eq!(
245            RemoteDesktopSideChannelGuard::read_transfer_size(&ok),
246            Ok(Some(1024))
247        );
248        let camel = serde_json::json!({"transferSize": 2048});
249        assert_eq!(
250            RemoteDesktopSideChannelGuard::read_transfer_size(&camel),
251            Ok(Some(2048))
252        );
253        let missing = serde_json::json!({});
254        assert_eq!(
255            RemoteDesktopSideChannelGuard::read_transfer_size(&missing),
256            Ok(None)
257        );
258        let bad = serde_json::json!({"transfer_size": "1024"});
259        assert_eq!(
260            RemoteDesktopSideChannelGuard::read_transfer_size(&bad),
261            Err(())
262        );
263    }
264}