chio_guards/
remote_desktop.rs1use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
34
35#[derive(Clone, Debug, Deserialize, Serialize)]
37#[serde(deny_unknown_fields)]
38pub struct RemoteDesktopSideChannelConfig {
39 #[serde(default = "default_true")]
41 pub enabled: bool,
42 #[serde(default = "default_true")]
44 pub clipboard_enabled: bool,
45 #[serde(default = "default_true")]
47 pub file_transfer_enabled: bool,
48 #[serde(default = "default_true")]
50 pub session_share_enabled: bool,
51 #[serde(default = "default_true")]
53 pub audio_enabled: bool,
54 #[serde(default = "default_true")]
56 pub drive_mapping_enabled: bool,
57 #[serde(default = "default_true")]
59 pub printing_enabled: bool,
60 #[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
84pub struct RemoteDesktopSideChannelGuard {
87 config: RemoteDesktopSideChannelConfig,
88}
89
90impl RemoteDesktopSideChannelGuard {
91 pub fn new() -> Self {
94 Self::with_config(RemoteDesktopSideChannelConfig::default())
95 }
96
97 pub fn with_config(config: RemoteDesktopSideChannelConfig) -> Self {
99 Self { config }
100 }
101
102 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 #[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 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 _ => Verdict::Deny,
219 };
220 Ok(GuardDecision::from_verdict(verdict))
221 }
222}
223
224fn 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")); 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}