Skip to main content

vtcode_safety/sandboxing/
exec_env.rs

1//! Command specification and execution environment types.
2
3use hashbrown::HashMap;
4use std::ffi::OsString;
5use std::path::PathBuf;
6use std::time::Duration;
7
8use tokio_util::sync::CancellationToken;
9
10use super::SandboxPermissions;
11
12/// Mechanism to terminate an exec invocation before it finishes naturally.
13#[derive(Debug, Clone, Default)]
14pub enum ExecExpiration {
15    /// Timeout after a specified duration.
16    Timeout(Duration),
17
18    /// Use the default timeout.
19    #[default]
20    DefaultTimeout,
21
22    /// Cancel via a cancellation token.
23    Cancellation(CancellationToken),
24}
25
26impl From<Option<u64>> for ExecExpiration {
27    fn from(timeout_ms: Option<u64>) -> Self {
28        match timeout_ms {
29            Some(ms) => Self::Timeout(Duration::from_millis(ms)),
30            None => Self::DefaultTimeout,
31        }
32    }
33}
34
35impl From<u64> for ExecExpiration {
36    fn from(timeout_ms: u64) -> Self {
37        Self::Timeout(Duration::from_millis(timeout_ms))
38    }
39}
40
41impl ExecExpiration {
42    /// Get the timeout in milliseconds, if applicable.
43    pub fn timeout_ms(&self) -> Option<u64> {
44        match self {
45            Self::Timeout(d) => Some(d.as_millis() as u64),
46            Self::DefaultTimeout => Some(30_000), // 30 second default
47            Self::Cancellation(_) => None,
48        }
49    }
50
51    /// Get the timeout duration, if applicable.
52    pub fn timeout_duration(&self) -> Option<Duration> {
53        match self {
54            Self::Timeout(d) => Some(*d),
55            Self::DefaultTimeout => Some(Duration::from_secs(30)),
56            Self::Cancellation(_) => None,
57        }
58    }
59}
60
61/// Specification for a command to be executed.
62#[derive(Debug, Clone)]
63pub struct CommandSpec {
64    /// The program to execute.
65    pub(crate) program: OsString,
66
67    /// Arguments to pass to the program.
68    pub(crate) args: Vec<String>,
69
70    /// Working directory for the command.
71    pub(crate) cwd: PathBuf,
72
73    /// Environment variables to set.
74    pub(crate) env: HashMap<String, String>,
75
76    /// Expiration mechanism for the command.
77    pub(crate) expiration: ExecExpiration,
78
79    /// Sandbox permissions for this command.
80    sandbox_permissions: SandboxPermissions,
81
82    /// Optional justification for why the command needs to run.
83    justification: Option<String>,
84}
85
86impl Default for CommandSpec {
87    fn default() -> Self {
88        Self {
89            program: OsString::new(),
90            args: Vec::new(),
91            cwd: PathBuf::new(),
92            env: HashMap::new(),
93            expiration: ExecExpiration::DefaultTimeout,
94            sandbox_permissions: SandboxPermissions::UseDefault,
95            justification: None,
96        }
97    }
98}
99
100impl CommandSpec {
101    /// Create a new command specification.
102    pub fn new(program: impl Into<OsString>) -> Self {
103        Self { program: program.into(), ..Default::default() }
104    }
105
106    /// Add arguments to the command.
107    pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
108        self.args = args.into_iter().map(Into::into).collect();
109        self
110    }
111
112    /// Set the working directory.
113    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
114        self.cwd = cwd.into();
115        self
116    }
117
118    /// Set environment variables.
119    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
120        self.env = env;
121        self
122    }
123
124    /// Set the expiration.
125    pub fn with_expiration(mut self, expiration: ExecExpiration) -> Self {
126        self.expiration = expiration;
127        self
128    }
129
130    /// Set sandbox permissions.
131    pub fn with_sandbox_permissions(mut self, permissions: SandboxPermissions) -> Self {
132        self.sandbox_permissions = permissions;
133        self
134    }
135
136    /// Set a justification.
137    fn with_justification(mut self, justification: impl Into<String>) -> Self {
138        self.justification = Some(justification.into());
139        self
140    }
141
142    /// Get the full command as a vector.
143    fn full_command(&self) -> Vec<OsString> {
144        let mut cmd = vec![self.program.clone()];
145        cmd.extend(self.args.iter().cloned().map(OsString::from));
146        cmd
147    }
148}
149
150/// The prepared execution environment after sandbox transformation.
151#[derive(Debug, Clone)]
152pub struct ExecEnv {
153    /// The program to execute (may be wrapped).
154    pub program: PathBuf,
155
156    /// Arguments to the program (may include sandbox wrapper args).
157    pub args: Vec<String>,
158
159    /// Working directory.
160    pub cwd: PathBuf,
161
162    /// Environment variables.
163    pub env: HashMap<String, String>,
164
165    /// Expiration mechanism.
166    pub expiration: ExecExpiration,
167
168    /// Whether the sandbox is active.
169    pub sandbox_active: bool,
170
171    /// Type of sandbox applied.
172    pub sandbox_type: SandboxType,
173}
174
175/// Type of sandbox being used.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum SandboxType {
178    /// No sandbox applied.
179    #[default]
180    None,
181
182    /// macOS Seatbelt sandbox.
183    MacosSeatbelt,
184
185    /// Linux Landlock + Seccomp sandbox.
186    LinuxLandlock,
187
188    /// Windows restricted token sandbox.
189    WindowsRestrictedToken,
190}
191
192impl SandboxType {
193    /// Get the platform-appropriate sandbox type.
194    pub(crate) fn platform_default() -> Self {
195        #[cfg(target_os = "macos")]
196        {
197            Self::MacosSeatbelt
198        }
199        #[cfg(target_os = "linux")]
200        {
201            Self::LinuxLandlock
202        }
203        #[cfg(target_os = "windows")]
204        {
205            Self::WindowsRestrictedToken
206        }
207        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
208        {
209            Self::None
210        }
211    }
212
213    /// Check if this sandbox type is available on the current platform.
214    pub(crate) fn is_available(&self) -> bool {
215        match self {
216            Self::None => true,
217            Self::MacosSeatbelt => cfg!(target_os = "macos"),
218            Self::LinuxLandlock => cfg!(target_os = "linux"),
219            Self::WindowsRestrictedToken => cfg!(target_os = "windows"),
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_command_spec_builder() {
230        let spec = CommandSpec::new("cat")
231            .with_args(vec!["file.txt"])
232            .with_cwd("/tmp")
233            .with_justification("testing");
234
235        assert_eq!(spec.program, OsString::from("cat"));
236        assert_eq!(spec.args, vec!["file.txt"]);
237        assert_eq!(spec.cwd, PathBuf::from("/tmp"));
238        assert_eq!(spec.justification, Some("testing".to_string()));
239    }
240
241    #[test]
242    fn test_full_command() {
243        let spec = CommandSpec::new("echo").with_args(vec!["hello", "world"]);
244
245        assert_eq!(spec.full_command(), vec![OsString::from("echo"), OsString::from("hello"), OsString::from("world")]);
246    }
247
248    #[test]
249    fn test_command_spec_accepts_path_backed_program() {
250        let program = PathBuf::from("/tmp/example-program");
251        let spec = CommandSpec::new(program.clone());
252
253        assert_eq!(spec.program, program.into_os_string());
254    }
255
256    #[test]
257    fn test_exec_expiration() {
258        let timeout = ExecExpiration::Timeout(Duration::from_secs(10));
259        assert_eq!(timeout.timeout_ms(), Some(10_000));
260
261        let default = ExecExpiration::DefaultTimeout;
262        assert_eq!(default.timeout_ms(), Some(30_000));
263    }
264}