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