Skip to main content

vtcode_safety/sandboxing/
permissions.rs

1//! Sandbox permissions for fine-grained access control.
2
3use std::path::PathBuf;
4
5use anyhow::anyhow;
6use serde::{Deserialize, Serialize};
7
8/// Fine-grained permissions for sandbox operations.
9///
10/// These permissions allow individual tool calls to request specific
11/// capabilities beyond the base sandbox policy.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SandboxPermissions {
15    /// Use the default sandbox permissions from the policy.
16    #[default]
17    UseDefault,
18
19    /// Request escalated permissions (requires approval).
20    RequireEscalated,
21
22    /// Request additional per-command sandbox permissions.
23    WithAdditionalPermissions,
24
25    /// Bypass the sandbox entirely (requires explicit approval).
26    BypassSandbox,
27}
28
29impl SandboxPermissions {
30    /// Canonical error when `with_additional_permissions` carries no paths.
31    pub const MISSING_ADDITIONAL_PERMISSIONS_MESSAGE: &str = "missing `additional_permissions`; provide `fs_read` and/or `fs_write` when using `with_additional_permissions`";
32    /// Canonical error when escalated/bypass requests lack justification.
33    pub const MISSING_ESCALATION_JUSTIFICATION_MESSAGE: &str =
34        "missing `justification`; provide a short approval question when using `sandbox_permissions=require_escalated`";
35    /// Canonical error when `additional_permissions` is paired with the wrong mode.
36    pub const ADDITIONAL_PERMISSIONS_MODE_MESSAGE: &str =
37        "`additional_permissions` requires `sandbox_permissions` set to `with_additional_permissions`";
38
39    /// Normalize an implicit additional-permission request to its explicit
40    /// sandboxed wire mode. Escalated and bypass modes are left unchanged so
41    /// their conflict checks remain fail-closed at the execution boundary.
42    #[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    /// Resolve and validate a raw sandbox request into a proven request.
52    ///
53    /// Modular proof: the permission impl itself establishes that the
54    /// supertrait-like obligations hold (mode/additional combination is
55    /// legal, escalated modes carry justification), so callers can trust the
56    /// returned [`ValidatedSandboxRequest`] instead of re-checking the
57    /// combination at every use site (external proof).
58    ///
59    /// Empty `additional_permissions` is treated as absent, `use_default`
60    /// plus non-empty additional normalizes to `with_additional_permissions`,
61    /// and escalated/bypass modes combined with additional permissions fail
62    /// closed rather than silently downgrading.
63    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    /// Check if this permission requires approval.
95    fn requires_approval(&self) -> bool {
96        matches!(self, Self::RequireEscalated | Self::WithAdditionalPermissions | Self::BypassSandbox)
97    }
98
99    /// Check if this permission requests full unsandboxed execution.
100    pub fn requires_escalated_permissions(&self) -> bool {
101        matches!(self, Self::RequireEscalated | Self::BypassSandbox)
102    }
103
104    /// Check if this permission requests any additional privileges.
105    fn requires_additional_permissions(&self) -> bool {
106        !matches!(self, Self::UseDefault)
107    }
108
109    /// Check if this permission requests additional sandboxed permissions.
110    pub fn uses_additional_permissions(&self) -> bool {
111        matches!(self, Self::WithAdditionalPermissions)
112    }
113
114    /// Check if this permission bypasses the sandbox.
115    fn bypasses_sandbox(&self) -> bool {
116        matches!(self, Self::BypassSandbox)
117    }
118
119    /// Merge with another permission, taking the more permissive one.
120    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/// A sandbox request proven valid by [`SandboxPermissions::resolve_request`].
132///
133/// Callers can trust the mode/additional combination without re-checking it:
134/// the impl established the obligations once, at construction.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ValidatedSandboxRequest {
137    permissions: SandboxPermissions,
138    additional: Option<AdditionalPermissions>,
139}
140
141impl ValidatedSandboxRequest {
142    /// The normalized permission mode.
143    #[must_use]
144    pub fn permissions(&self) -> SandboxPermissions {
145        self.permissions
146    }
147
148    /// The validated additional permissions, if any.
149    #[must_use]
150    pub fn additional(&self) -> Option<&AdditionalPermissions> {
151        self.additional.as_ref()
152    }
153
154    /// Decompose into the trusted mode/additional pair.
155    #[must_use]
156    pub fn into_parts(self) -> (SandboxPermissions, Option<AdditionalPermissions>) {
157        (self.permissions, self.additional)
158    }
159}
160
161/// Additional per-command filesystem permissions.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
163pub struct AdditionalPermissions {
164    /// Additional filesystem paths to grant read access.
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub fs_read: Vec<PathBuf>,
167    /// Additional filesystem paths to grant write access.
168    #[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}