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 requests full unsandboxed execution.
95    pub fn requires_escalated_permissions(&self) -> bool {
96        matches!(self, Self::RequireEscalated | Self::BypassSandbox)
97    }
98
99    /// Check if this permission requests additional sandboxed permissions.
100    pub fn uses_additional_permissions(&self) -> bool {
101        matches!(self, Self::WithAdditionalPermissions)
102    }
103}
104
105/// A sandbox request proven valid by [`SandboxPermissions::resolve_request`].
106///
107/// Callers can trust the mode/additional combination without re-checking it:
108/// the impl established the obligations once, at construction.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ValidatedSandboxRequest {
111    permissions: SandboxPermissions,
112    additional: Option<AdditionalPermissions>,
113}
114
115impl ValidatedSandboxRequest {
116    /// The normalized permission mode.
117    #[must_use]
118    pub fn permissions(&self) -> SandboxPermissions {
119        self.permissions
120    }
121
122    /// The validated additional permissions, if any.
123    #[must_use]
124    pub fn additional(&self) -> Option<&AdditionalPermissions> {
125        self.additional.as_ref()
126    }
127
128    /// Decompose into the trusted mode/additional pair.
129    #[must_use]
130    pub fn into_parts(self) -> (SandboxPermissions, Option<AdditionalPermissions>) {
131        (self.permissions, self.additional)
132    }
133}
134
135/// Additional per-command filesystem permissions.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
137pub struct AdditionalPermissions {
138    /// Additional filesystem paths to grant read access.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub fs_read: Vec<PathBuf>,
141    /// Additional filesystem paths to grant write access.
142    #[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}