vtcode_safety/sandboxing/
permissions.rs1use std::path::PathBuf;
4
5use anyhow::anyhow;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SandboxPermissions {
15 #[default]
17 UseDefault,
18
19 RequireEscalated,
21
22 WithAdditionalPermissions,
24
25 BypassSandbox,
27}
28
29impl SandboxPermissions {
30 pub const MISSING_ADDITIONAL_PERMISSIONS_MESSAGE: &str = "missing `additional_permissions`; provide `fs_read` and/or `fs_write` when using `with_additional_permissions`";
32 pub const MISSING_ESCALATION_JUSTIFICATION_MESSAGE: &str =
34 "missing `justification`; provide a short approval question when using `sandbox_permissions=require_escalated`";
35 pub const ADDITIONAL_PERMISSIONS_MODE_MESSAGE: &str =
37 "`additional_permissions` requires `sandbox_permissions` set to `with_additional_permissions`";
38
39 #[must_use]
43 pub fn normalized_for(self, additional_permissions: Option<&AdditionalPermissions>) -> Self {
44 if self == Self::UseDefault && additional_permissions.is_some_and(|permissions| !permissions.is_empty()) {
45 Self::WithAdditionalPermissions
46 } else {
47 self
48 }
49 }
50
51 pub fn resolve_request(
64 self,
65 additional_permissions: Option<AdditionalPermissions>,
66 justification: Option<&str>,
67 ) -> anyhow::Result<ValidatedSandboxRequest> {
68 let additional_permissions = additional_permissions.filter(|permissions| !permissions.is_empty());
69 let normalized = self.normalized_for(additional_permissions.as_ref());
70
71 if normalized.requires_escalated_permissions() {
72 let justification = justification.map(str::trim).unwrap_or_default();
73 if justification.is_empty() {
74 return Err(anyhow!(Self::MISSING_ESCALATION_JUSTIFICATION_MESSAGE));
75 }
76 }
77
78 if normalized.uses_additional_permissions() {
79 let Some(additional_permissions) = additional_permissions else {
80 return Err(anyhow!(Self::MISSING_ADDITIONAL_PERMISSIONS_MESSAGE));
81 };
82 Ok(ValidatedSandboxRequest {
83 permissions: normalized,
84 additional: Some(additional_permissions),
85 })
86 } else {
87 if additional_permissions.is_some() {
88 return Err(anyhow!(Self::ADDITIONAL_PERMISSIONS_MODE_MESSAGE));
89 }
90 Ok(ValidatedSandboxRequest { permissions: normalized, additional: None })
91 }
92 }
93
94 fn requires_approval(&self) -> bool {
96 matches!(self, Self::RequireEscalated | Self::WithAdditionalPermissions | Self::BypassSandbox)
97 }
98
99 pub fn requires_escalated_permissions(&self) -> bool {
101 matches!(self, Self::RequireEscalated | Self::BypassSandbox)
102 }
103
104 fn requires_additional_permissions(&self) -> bool {
106 !matches!(self, Self::UseDefault)
107 }
108
109 pub fn uses_additional_permissions(&self) -> bool {
111 matches!(self, Self::WithAdditionalPermissions)
112 }
113
114 fn bypasses_sandbox(&self) -> bool {
116 matches!(self, Self::BypassSandbox)
117 }
118
119 fn merge(&self, other: &Self) -> Self {
121 use SandboxPermissions::*;
122 match (self, other) {
123 (BypassSandbox, _) | (_, BypassSandbox) => BypassSandbox,
124 (RequireEscalated, _) | (_, RequireEscalated) => RequireEscalated,
125 (WithAdditionalPermissions, _) | (_, WithAdditionalPermissions) => WithAdditionalPermissions,
126 (UseDefault, UseDefault) => UseDefault,
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ValidatedSandboxRequest {
137 permissions: SandboxPermissions,
138 additional: Option<AdditionalPermissions>,
139}
140
141impl ValidatedSandboxRequest {
142 #[must_use]
144 pub fn permissions(&self) -> SandboxPermissions {
145 self.permissions
146 }
147
148 #[must_use]
150 pub fn additional(&self) -> Option<&AdditionalPermissions> {
151 self.additional.as_ref()
152 }
153
154 #[must_use]
156 pub fn into_parts(self) -> (SandboxPermissions, Option<AdditionalPermissions>) {
157 (self.permissions, self.additional)
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
163pub struct AdditionalPermissions {
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub fs_read: Vec<PathBuf>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub fs_write: Vec<PathBuf>,
170}
171
172impl AdditionalPermissions {
173 pub fn is_empty(&self) -> bool {
174 self.fs_read.is_empty() && self.fs_write.is_empty()
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_default_permissions() {
184 let perm = SandboxPermissions::default();
185 assert!(!perm.requires_approval());
186 assert!(!perm.bypasses_sandbox());
187 }
188
189 #[test]
190 fn test_escalated_permissions() {
191 let perm = SandboxPermissions::RequireEscalated;
192 assert!(perm.requires_approval());
193 assert!(!perm.bypasses_sandbox());
194 assert!(perm.requires_escalated_permissions());
195 }
196
197 #[test]
198 fn test_bypass_permissions() {
199 let perm = SandboxPermissions::BypassSandbox;
200 assert!(perm.requires_approval());
201 assert!(perm.bypasses_sandbox());
202 assert!(perm.requires_escalated_permissions());
203 }
204
205 #[test]
206 fn test_with_additional_permissions() {
207 let perm = SandboxPermissions::WithAdditionalPermissions;
208 assert!(perm.requires_approval());
209 assert!(perm.requires_additional_permissions());
210 assert!(perm.uses_additional_permissions());
211 assert!(!perm.requires_escalated_permissions());
212 assert!(!perm.bypasses_sandbox());
213 }
214
215 #[test]
216 fn default_normalizes_for_non_empty_additional_permissions() {
217 let additional = AdditionalPermissions {
218 fs_read: vec![PathBuf::from("/tmp/reference")],
219 fs_write: Vec::new(),
220 };
221
222 assert_eq!(
223 SandboxPermissions::UseDefault.normalized_for(Some(&additional)),
224 SandboxPermissions::WithAdditionalPermissions
225 );
226 assert_eq!(
227 SandboxPermissions::RequireEscalated.normalized_for(Some(&additional)),
228 SandboxPermissions::RequireEscalated
229 );
230 assert_eq!(
231 SandboxPermissions::UseDefault.normalized_for(Some(&AdditionalPermissions::default())),
232 SandboxPermissions::UseDefault
233 );
234 }
235
236 #[test]
237 fn test_merge_permissions() {
238 use SandboxPermissions::*;
239
240 assert_eq!(UseDefault.merge(&UseDefault), UseDefault);
241 assert_eq!(UseDefault.merge(&WithAdditionalPermissions), WithAdditionalPermissions);
242 assert_eq!(UseDefault.merge(&RequireEscalated), RequireEscalated);
243 assert_eq!(RequireEscalated.merge(&BypassSandbox), BypassSandbox);
244 }
245
246 fn non_empty_additional() -> AdditionalPermissions {
247 AdditionalPermissions {
248 fs_read: vec![PathBuf::from("/tmp/reference")],
249 fs_write: Vec::new(),
250 }
251 }
252
253 #[test]
254 fn resolve_request_normalizes_default_with_additional() {
255 let validated = SandboxPermissions::UseDefault
256 .resolve_request(Some(non_empty_additional()), None)
257 .expect("use_default plus additional should normalize");
258 let (permissions, additional) = validated.into_parts();
259 assert_eq!(permissions, SandboxPermissions::WithAdditionalPermissions);
260 assert!(additional.is_some_and(|permissions| !permissions.is_empty()));
261 }
262
263 #[test]
264 fn resolve_request_treats_empty_additional_as_absent() {
265 let validated = SandboxPermissions::UseDefault
266 .resolve_request(Some(AdditionalPermissions::default()), None)
267 .expect("empty additional should be ignored");
268 let (permissions, additional) = validated.into_parts();
269 assert_eq!(permissions, SandboxPermissions::UseDefault);
270 assert!(additional.is_none());
271 }
272
273 #[test]
274 fn resolve_request_rejects_with_additional_without_paths() {
275 let err = SandboxPermissions::WithAdditionalPermissions
276 .resolve_request(None, None)
277 .expect_err("with_additional_permissions without paths should fail");
278 assert!(err.to_string().contains("missing `additional_permissions`"));
279 }
280
281 #[test]
282 fn resolve_request_rejects_escalated_without_justification() {
283 for mode in [SandboxPermissions::RequireEscalated, SandboxPermissions::BypassSandbox] {
284 let err = mode
285 .resolve_request(None, None)
286 .expect_err("escalated modes require justification");
287 assert!(err.to_string().contains("missing `justification`"));
288
289 let err = mode
290 .resolve_request(None, Some(" "))
291 .expect_err("blank justification should fail");
292 assert!(err.to_string().contains("missing `justification`"));
293 }
294 }
295
296 #[test]
297 fn resolve_request_rejects_escalated_with_additional_permissions() {
298 for mode in [SandboxPermissions::RequireEscalated, SandboxPermissions::BypassSandbox] {
299 let err = mode
300 .resolve_request(Some(non_empty_additional()), Some("operator approved"))
301 .expect_err("escalated modes must not combine additional permissions");
302 assert!(
303 err.to_string()
304 .contains("requires `sandbox_permissions` set to `with_additional_permissions`")
305 );
306 }
307 }
308
309 #[test]
310 fn resolve_request_accepts_justified_escalated_request() {
311 let validated = SandboxPermissions::RequireEscalated
312 .resolve_request(None, Some("operator approved"))
313 .expect("justified escalated request should resolve");
314 let (permissions, additional) = validated.into_parts();
315 assert_eq!(permissions, SandboxPermissions::RequireEscalated);
316 assert!(additional.is_none());
317 }
318}