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 pub fn requires_escalated_permissions(&self) -> bool {
96 matches!(self, Self::RequireEscalated | Self::BypassSandbox)
97 }
98
99 pub fn uses_additional_permissions(&self) -> bool {
101 matches!(self, Self::WithAdditionalPermissions)
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ValidatedSandboxRequest {
111 permissions: SandboxPermissions,
112 additional: Option<AdditionalPermissions>,
113}
114
115impl ValidatedSandboxRequest {
116 #[must_use]
118 pub fn permissions(&self) -> SandboxPermissions {
119 self.permissions
120 }
121
122 #[must_use]
124 pub fn additional(&self) -> Option<&AdditionalPermissions> {
125 self.additional.as_ref()
126 }
127
128 #[must_use]
130 pub fn into_parts(self) -> (SandboxPermissions, Option<AdditionalPermissions>) {
131 (self.permissions, self.additional)
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
137pub struct AdditionalPermissions {
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub fs_read: Vec<PathBuf>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub fs_write: Vec<PathBuf>,
144}
145
146impl AdditionalPermissions {
147 pub fn is_empty(&self) -> bool {
148 self.fs_read.is_empty() && self.fs_write.is_empty()
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn test_default_permissions() {
158 let perm = SandboxPermissions::default();
159 assert_eq!(perm, SandboxPermissions::UseDefault);
160 assert!(!perm.requires_escalated_permissions());
161 assert!(!perm.uses_additional_permissions());
162 }
163
164 #[test]
165 fn test_escalated_permissions() {
166 let perm = SandboxPermissions::RequireEscalated;
167 assert!(perm.requires_escalated_permissions());
168 }
169
170 #[test]
171 fn test_bypass_permissions() {
172 let perm = SandboxPermissions::BypassSandbox;
173 assert!(perm.requires_escalated_permissions());
174 }
175
176 #[test]
177 fn test_with_additional_permissions() {
178 let perm = SandboxPermissions::WithAdditionalPermissions;
179 assert!(perm.uses_additional_permissions());
180 assert!(!perm.requires_escalated_permissions());
181 }
182
183 #[test]
184 fn default_normalizes_for_non_empty_additional_permissions() {
185 let additional = AdditionalPermissions {
186 fs_read: vec![PathBuf::from("/tmp/reference")],
187 fs_write: Vec::new(),
188 };
189
190 assert_eq!(
191 SandboxPermissions::UseDefault.normalized_for(Some(&additional)),
192 SandboxPermissions::WithAdditionalPermissions
193 );
194 assert_eq!(
195 SandboxPermissions::RequireEscalated.normalized_for(Some(&additional)),
196 SandboxPermissions::RequireEscalated
197 );
198 assert_eq!(
199 SandboxPermissions::UseDefault.normalized_for(Some(&AdditionalPermissions::default())),
200 SandboxPermissions::UseDefault
201 );
202 }
203
204 fn non_empty_additional() -> AdditionalPermissions {
205 AdditionalPermissions {
206 fs_read: vec![PathBuf::from("/tmp/reference")],
207 fs_write: Vec::new(),
208 }
209 }
210
211 #[test]
212 fn resolve_request_normalizes_default_with_additional() {
213 let validated = SandboxPermissions::UseDefault
214 .resolve_request(Some(non_empty_additional()), None)
215 .expect("use_default plus additional should normalize");
216 let (permissions, additional) = validated.into_parts();
217 assert_eq!(permissions, SandboxPermissions::WithAdditionalPermissions);
218 assert!(additional.is_some_and(|permissions| !permissions.is_empty()));
219 }
220
221 #[test]
222 fn resolve_request_treats_empty_additional_as_absent() {
223 let validated = SandboxPermissions::UseDefault
224 .resolve_request(Some(AdditionalPermissions::default()), None)
225 .expect("empty additional should be ignored");
226 let (permissions, additional) = validated.into_parts();
227 assert_eq!(permissions, SandboxPermissions::UseDefault);
228 assert!(additional.is_none());
229 }
230
231 #[test]
232 fn resolve_request_rejects_with_additional_without_paths() {
233 let err = SandboxPermissions::WithAdditionalPermissions
234 .resolve_request(None, None)
235 .expect_err("with_additional_permissions without paths should fail");
236 assert!(err.to_string().contains("missing `additional_permissions`"));
237 }
238
239 #[test]
240 fn resolve_request_rejects_escalated_without_justification() {
241 for mode in [SandboxPermissions::RequireEscalated, SandboxPermissions::BypassSandbox] {
242 let err = mode
243 .resolve_request(None, None)
244 .expect_err("escalated modes require justification");
245 assert!(err.to_string().contains("missing `justification`"));
246
247 let err = mode
248 .resolve_request(None, Some(" "))
249 .expect_err("blank justification should fail");
250 assert!(err.to_string().contains("missing `justification`"));
251 }
252 }
253
254 #[test]
255 fn resolve_request_rejects_escalated_with_additional_permissions() {
256 for mode in [SandboxPermissions::RequireEscalated, SandboxPermissions::BypassSandbox] {
257 let err = mode
258 .resolve_request(Some(non_empty_additional()), Some("operator approved"))
259 .expect_err("escalated modes must not combine additional permissions");
260 assert!(
261 err.to_string()
262 .contains("requires `sandbox_permissions` set to `with_additional_permissions`")
263 );
264 }
265 }
266
267 #[test]
268 fn resolve_request_accepts_justified_escalated_request() {
269 let validated = SandboxPermissions::RequireEscalated
270 .resolve_request(None, Some("operator approved"))
271 .expect("justified escalated request should resolve");
272 let (permissions, additional) = validated.into_parts();
273 assert_eq!(permissions, SandboxPermissions::RequireEscalated);
274 assert!(additional.is_none());
275 }
276}