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(u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
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/// How the Linux sandbox helper binary is invoked.
176///
177/// The default helper is the `vtcode` binary itself (busybox pattern): the
178/// launcher subcommand token must be prepended before the helper-protocol
179/// flags. An external helper configured via `VTCODE_LINUX_SANDBOX_EXECUTABLE`
180/// receives the protocol flags directly.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct LinuxSandboxLauncher {
183    /// The helper program to execute.
184    pub program: PathBuf,
185    /// Arguments inserted before the helper-protocol flags (e.g. the
186    /// `sandbox-exec` subcommand token for busybox dispatch).
187    pub prefix_args: Vec<String>,
188}
189
190impl LinuxSandboxLauncher {
191    /// An external helper binary invoked as `<helper> --sandbox-policy …`.
192    pub fn external(helper: PathBuf) -> Self {
193        Self { program: helper, prefix_args: Vec::new() }
194    }
195
196    /// VT Code itself, dispatched to its hidden `sandbox-exec` subcommand.
197    pub fn busybox(binary: PathBuf) -> Self {
198        Self {
199            program: binary,
200            prefix_args: vec!["sandbox-exec".to_string()],
201        }
202    }
203
204    /// Resolve the Linux sandbox helper: an explicit
205    /// `VTCODE_LINUX_SANDBOX_EXECUTABLE` override is invoked directly;
206    /// otherwise VT Code itself is the helper via busybox dispatch.
207    #[cfg(target_os = "linux")]
208    pub fn resolve() -> Option<Self> {
209        if let Some(helper) = std::env::var_os("VTCODE_LINUX_SANDBOX_EXECUTABLE") {
210            return Some(Self::external(helper.into()));
211        }
212        std::env::current_exe().ok().map(Self::busybox)
213    }
214
215    #[cfg(not(target_os = "linux"))]
216    pub fn resolve() -> Option<Self> {
217        None
218    }
219}
220
221/// Type of sandbox being used.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223pub enum SandboxType {
224    /// No sandbox applied.
225    #[default]
226    None,
227
228    /// macOS Seatbelt sandbox.
229    MacosSeatbelt,
230
231    /// Linux Landlock + Seccomp sandbox.
232    LinuxLandlock,
233
234    /// Windows restricted token sandbox.
235    WindowsRestrictedToken,
236}
237
238impl SandboxType {
239    /// Get the platform-appropriate sandbox type.
240    pub(crate) fn platform_default() -> Self {
241        #[cfg(target_os = "macos")]
242        {
243            Self::MacosSeatbelt
244        }
245        #[cfg(target_os = "linux")]
246        {
247            Self::LinuxLandlock
248        }
249        #[cfg(target_os = "windows")]
250        {
251            Self::WindowsRestrictedToken
252        }
253        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
254        {
255            Self::None
256        }
257    }
258
259    /// Check if this sandbox type is available on the current platform.
260    ///
261    /// "Available" means the sandbox is **actually enforced**, not just that
262    /// the type compiles on this platform. `WindowsRestrictedToken` is not
263    /// yet implemented (the transform is a no-op pass-through), so it reports
264    /// `false` on all platforms — callers who request a restrictive policy on
265    /// Windows get an `UnavailableSandboxType` error instead of silently
266    /// running unsandboxed.
267    pub(crate) fn is_available(&self) -> bool {
268        match self {
269            Self::None => true,
270            Self::MacosSeatbelt => cfg!(target_os = "macos"),
271            // Available only when the kernel actually enforces Landlock
272            // (Linux 5.13+); older kernels fail closed.
273            #[cfg(target_os = "linux")]
274            Self::LinuxLandlock => super::linux::landlock_supported(),
275            #[cfg(not(target_os = "linux"))]
276            Self::LinuxLandlock => false,
277            // Not yet implemented — `transform_windows` is a pass-through.
278            // Returning `false` here causes fail-closed behavior: requesting
279            // a ReadOnly/WorkspaceWrite policy on Windows yields an explicit
280            // error rather than silently running without a sandbox.
281            Self::WindowsRestrictedToken => false,
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_command_spec_builder() {
292        let spec = CommandSpec::new("cat")
293            .with_args(vec!["file.txt"])
294            .with_cwd("/tmp")
295            .with_justification("testing");
296
297        assert_eq!(spec.program, OsString::from("cat"));
298        assert_eq!(spec.args, vec!["file.txt"]);
299        assert_eq!(spec.cwd, PathBuf::from("/tmp"));
300        assert_eq!(spec.justification, Some("testing".to_string()));
301    }
302
303    #[test]
304    fn test_full_command() {
305        let spec = CommandSpec::new("echo").with_args(vec!["hello", "world"]);
306
307        assert_eq!(spec.full_command(), vec![OsString::from("echo"), OsString::from("hello"), OsString::from("world")]);
308    }
309
310    #[test]
311    fn test_command_spec_accepts_path_backed_program() {
312        let program = PathBuf::from("/tmp/example-program");
313        let spec = CommandSpec::new(program.clone());
314
315        assert_eq!(spec.program, program.into_os_string());
316    }
317
318    #[test]
319    fn test_exec_expiration() {
320        let timeout = ExecExpiration::Timeout(Duration::from_secs(10));
321        assert_eq!(timeout.timeout_ms(), Some(10_000));
322
323        let default = ExecExpiration::DefaultTimeout;
324        assert_eq!(default.timeout_ms(), Some(30_000));
325    }
326}