vtcode-safety 0.146.4

Command safety detection, execution policies, and sandboxing for VT Code
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Sandbox manager for transforming commands into sandboxed execution environments.

use std::ffi::OsString;
use std::path::Path;

use super::child_spawn::filter_sensitive_env;
use super::exec_env::{CommandSpec, ExecEnv, SandboxType};
#[cfg(target_os = "macos")]
use super::policy::NetworkAllowlistEntry;
use super::policy::SandboxPolicy;

/// Error type for sandbox transformation failures.
#[derive(Debug, thiserror::Error)]
pub enum SandboxTransformError {
    #[error("missing sandbox executable path")]
    MissingSandboxExecutable,

    #[error("sandbox type {0:?} is not available on this platform")]
    UnavailableSandboxType(SandboxType),

    #[error("failed to create sandbox environment: {0}")]
    CreationFailed(String),

    #[error("invalid sandbox policy: {0}")]
    InvalidPolicy(String),
}

/// Manager for sandbox transformation.
///
/// Transforms a `CommandSpec` into an `ExecEnv` by applying the appropriate
/// sandbox wrapper based on the platform and policy.
#[derive(Debug, Default)]
pub struct SandboxManager;

impl SandboxManager {
    /// Create a new sandbox manager.
    pub fn new() -> Self {
        Self
    }

    /// Transform a command specification into a sandboxed execution environment.
    pub fn transform(
        &self,
        spec: CommandSpec,
        policy: &SandboxPolicy,
        sandbox_cwd: &Path,
        sandbox_executable: Option<&Path>,
    ) -> Result<ExecEnv, SandboxTransformError> {
        // Determine the sandbox type based on policy and platform
        let sandbox_type = self.determine_sandbox_type(policy)?;

        // A restrictive sandbox must not inherit secrets or dynamic-loader
        // controls, including values supplied by a caller through `spec.env`.
        // Full-access and externally managed policies intentionally preserve
        // the caller's environment because this manager is not their boundary.
        let spec = if sandbox_type == SandboxType::None {
            spec
        } else {
            let mut spec = spec;
            spec.env = filter_sensitive_env(&spec.env);
            spec
        };

        // If no sandbox needed or full access, return direct execution
        if sandbox_type == SandboxType::None {
            return Ok(ExecEnv {
                program: spec.program.into(),
                args: spec.args,
                cwd: spec.cwd,
                env: spec.env,
                expiration: spec.expiration,
                sandbox_active: false,
                sandbox_type: SandboxType::None,
            });
        }

        // Check sandbox availability
        if !sandbox_type.is_available() {
            return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
        }

        // Transform based on sandbox type
        match sandbox_type {
            SandboxType::MacosSeatbelt => self.transform_seatbelt(spec, policy, sandbox_cwd),
            SandboxType::LinuxLandlock => self.transform_landlock(spec, policy, sandbox_cwd, sandbox_executable),
            SandboxType::WindowsRestrictedToken => self.transform_windows(spec, policy, sandbox_cwd),
            SandboxType::None => {
                Err(SandboxTransformError::InvalidPolicy("Cannot transform with SandboxType::None".into()))
            }
        }
    }

    /// Determine the appropriate sandbox type for the given policy.
    fn determine_sandbox_type(&self, policy: &SandboxPolicy) -> Result<SandboxType, SandboxTransformError> {
        match policy {
            SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Ok(SandboxType::None),
            SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => {
                Ok(SandboxType::platform_default())
            }
        }
    }

    /// Transform for macOS Seatbelt sandbox.
    #[cfg(target_os = "macos")]
    fn transform_seatbelt(
        &self,
        spec: CommandSpec,
        policy: &SandboxPolicy,
        sandbox_cwd: &Path,
    ) -> Result<ExecEnv, SandboxTransformError> {
        const SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";

        // Build the seatbelt profile
        let profile = self.build_seatbelt_profile(policy, sandbox_cwd)?;

        let mut args = vec!["-p".to_string(), profile, os_string_to_arg(spec.program.clone())];
        args.extend(spec.args);

        Ok(ExecEnv {
            program: SEATBELT_EXECUTABLE.into(),
            args,
            cwd: spec.cwd,
            env: spec.env,
            expiration: spec.expiration,
            sandbox_active: true,
            sandbox_type: SandboxType::MacosSeatbelt,
        })
    }

    #[cfg(not(target_os = "macos"))]
    fn transform_seatbelt(
        &self,
        _spec: CommandSpec,
        _policy: &SandboxPolicy,
        _sandbox_cwd: &Path,
    ) -> Result<ExecEnv, SandboxTransformError> {
        Err(SandboxTransformError::UnavailableSandboxType(SandboxType::MacosSeatbelt))
    }

    /// Build a seatbelt profile string.
    ///
    /// Implements the field guide's recommendations:
    /// - "Default-deny outbound network, then allowlist."
    /// - Block sensitive paths to prevent credential leakage.
    #[cfg(target_os = "macos")]
    fn build_seatbelt_profile(
        &self,
        policy: &SandboxPolicy,
        sandbox_cwd: &Path,
    ) -> Result<String, SandboxTransformError> {
        fn append_network_rules(
            profile: &mut String,
            network_access: bool,
            network_allowlist: &[NetworkAllowlistEntry],
        ) -> Result<(), SandboxTransformError> {
            if !network_allowlist.is_empty() {
                return Err(SandboxTransformError::InvalidPolicy(
                    "macOS Seatbelt cannot enforce hostname network allowlists exactly; refusing to widen access"
                        .to_string(),
                ));
            }

            if !network_access {
                // Keep local unix sockets available even when outbound network is restricted.
                profile.push_str("(allow network* (local unix))\n");
            }
            if network_access {
                profile.push_str("(allow network*)\n");
            }
            Ok(())
        }

        let mut profile = String::from("(version 1)\n");
        profile.push_str("(deny default)\n");
        profile.push_str("(allow process-exec)\n");
        profile.push_str("(allow process-fork)\n");
        profile.push_str("(allow sysctl-read)\n");
        profile.push_str("(allow mach-lookup)\n");
        profile.push_str("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))\n");
        profile.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.daemon\") (global-name \"com.apple.cfprefsd.agent\") (local-name \"com.apple.cfprefsd.agent\"))\n");
        profile.push_str("(allow user-preference-read)\n");

        // Block sensitive paths BEFORE allowing general read access
        // This ensures deny rules take precedence
        let sensitive_paths = policy.sensitive_paths_for_execution(sandbox_cwd);
        for sp in &sensitive_paths {
            let expanded = sp.expand_path();
            let path_str = expanded.display();
            if sp.block_read {
                profile.push_str(&format!("(deny file-read* (subpath \"{path_str}\"))\n"));
            }
            if sp.block_write {
                profile.push_str(&format!("(deny file-write* (subpath \"{path_str}\"))\n"));
            }
        }

        // Allow reading from everywhere (except denied sensitive paths above)
        profile.push_str("(allow file-read*)\n");

        match policy {
            SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
                // Read-only: only allow writing to /dev/null
                profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
                append_network_rules(&mut profile, *network_access, network_allowlist)?;
            }
            SandboxPolicy::WorkspaceWrite { network_access, network_allowlist, .. } => {
                for root in policy.get_writable_roots_with_cwd(sandbox_cwd) {
                    let path = root.root.display();
                    profile.push_str(&format!("(allow file-write* (subpath \"{path}\"))\n"));
                }
                append_network_rules(&mut profile, *network_access, network_allowlist)?;
            }
            _ => {}
        }

        Ok(profile)
    }

    /// Transform for Linux Landlock sandbox.
    ///
    /// Following the field guide: "Landlock + seccomp is the recommended Linux pattern."
    /// The sandbox helper binary receives both the policy (for Landlock filesystem rules)
    /// and the seccomp profile (for syscall filtering).
    fn transform_landlock(
        &self,
        spec: CommandSpec,
        policy: &SandboxPolicy,
        sandbox_cwd: &Path,
        sandbox_executable: Option<&Path>,
    ) -> Result<ExecEnv, SandboxTransformError> {
        let sandbox_exe = sandbox_executable.ok_or(SandboxTransformError::MissingSandboxExecutable)?;

        // Serialize the policy for the sandbox helper (includes Landlock rules)
        let policy_json = serde_json::to_string(policy)
            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;

        // Serialize seccomp profile separately for explicit syscall filtering
        let seccomp_profile = policy.seccomp_profile();
        let seccomp_json = seccomp_profile
            .to_json()
            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize seccomp profile: {e}")))?;

        // Serialize resource limits for cgroup/rlimit enforcement
        let resource_limits = policy.resource_limits();
        let limits_json = serde_json::to_string(&resource_limits)
            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize resource limits: {e}")))?;

        let sandbox_cwd_str = sandbox_cwd.to_string_lossy().to_string();

        let mut args = vec![
            "--sandbox-policy-cwd".to_string(),
            sandbox_cwd_str,
            "--sandbox-policy".to_string(),
            policy_json,
            "--seccomp-profile".to_string(),
            seccomp_json,
            "--resource-limits".to_string(),
            limits_json,
            "--".to_string(),
            os_string_to_arg(spec.program.clone()),
        ];
        args.extend(spec.args);

        Ok(ExecEnv {
            program: sandbox_exe.to_path_buf(),
            args,
            cwd: spec.cwd,
            env: spec.env,
            expiration: spec.expiration,
            sandbox_active: true,
            sandbox_type: SandboxType::LinuxLandlock,
        })
    }

    /// Transform for Windows restricted token sandbox.
    ///
    /// Not yet implemented. Returns `UnavailableSandboxType` so that callers
    /// requesting a restrictive policy on Windows get an explicit error
    /// instead of silently running unsandboxed. The `is_available()` check in
    /// `transform()` normally catches this first, but this guard ensures
    /// fail-closed behavior even if the availability check is bypassed.
    fn transform_windows(
        &self,
        _spec: CommandSpec,
        _policy: &SandboxPolicy,
        _sandbox_cwd: &Path,
    ) -> Result<ExecEnv, SandboxTransformError> {
        Err(SandboxTransformError::UnavailableSandboxType(SandboxType::WindowsRestrictedToken))
    }
}

fn os_string_to_arg(value: OsString) -> String {
    value.into_string().unwrap_or_else(|value| value.to_string_lossy().into_owned())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_no_sandbox_for_full_access() {
        let manager = SandboxManager::new();
        let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
        let policy = SandboxPolicy::full_access();

        let env = manager.transform(spec, &policy, Path::new("/tmp"), None).unwrap();

        assert!(!env.sandbox_active);
        assert_eq!(env.sandbox_type, SandboxType::None);
    }

    #[test]
    fn test_sandbox_type_determination() {
        let manager = SandboxManager::new();

        // Full access = no sandbox
        let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
        assert_eq!(result.unwrap(), SandboxType::None);

        // Read-only = platform default
        let result = manager.determine_sandbox_type(&SandboxPolicy::read_only());
        assert_eq!(result.unwrap(), SandboxType::platform_default());
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn seatbelt_profile_includes_default_preferences_policy() {
        let manager = SandboxManager::new();
        let profile = manager
            .build_seatbelt_profile(&SandboxPolicy::read_only(), Path::new("/tmp"))
            .unwrap();

        assert!(profile.contains("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"));
        assert!(profile.contains("(global-name \"com.apple.cfprefsd.daemon\")"));
        assert!(profile.contains("(global-name \"com.apple.cfprefsd.agent\")"));
        assert!(profile.contains("(local-name \"com.apple.cfprefsd.agent\")"));
        assert!(profile.contains("(allow user-preference-read)"));
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn seatbelt_rejects_hostname_allowlist_without_exact_enforcement() {
        let manager = SandboxManager::new();
        let policy = SandboxPolicy::read_only_with_network(vec![NetworkAllowlistEntry::https("api.example.com")]);

        let result = manager.build_seatbelt_profile(&policy, Path::new("/tmp"));

        assert!(matches!(result, Err(SandboxTransformError::InvalidPolicy(message)) if message.contains("hostname")));
    }

    /// Windows restricted-token sandbox is not yet implemented, so
    /// `is_available()` must return `false` on **all** platforms. This
    /// prevents silent pass-through when a restrictive policy is requested.
    #[test]
    fn windows_restricted_token_is_not_available() {
        assert!(!SandboxType::WindowsRestrictedToken.is_available());
    }

    /// `transform_windows` must fail-closed with `UnavailableSandboxType`,
    /// not silently pass the command through unsandboxed.
    #[test]
    fn transform_windows_fails_closed() {
        let manager = SandboxManager::new();
        let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
        let result = manager.transform_windows(spec, &SandboxPolicy::read_only(), Path::new("/tmp"));

        assert!(matches!(
            result,
            Err(SandboxTransformError::UnavailableSandboxType(SandboxType::WindowsRestrictedToken))
        ));
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn restrictive_linux_policy_requires_sandbox_helper() {
        let manager = SandboxManager::new();
        let spec = CommandSpec::new("echo").with_args(vec!["hello"]);

        let result = manager.transform(spec, &SandboxPolicy::read_only(), Path::new("/tmp"), None);

        assert!(matches!(result, Err(SandboxTransformError::MissingSandboxExecutable)));
    }

    #[cfg(any(target_os = "linux", target_os = "macos"))]
    #[test]
    fn restrictive_policy_filters_sensitive_environment_overrides() {
        use hashbrown::HashMap;

        let manager = SandboxManager::new();
        let mut env = HashMap::new();
        drop(env.insert("OPENAI_API_KEY".to_string(), "secret-value".to_string()));
        drop(env.insert("LD_PRELOAD".to_string(), "injected.so".to_string()));
        drop(env.insert("SAFE_PROJECT_NAME".to_string(), "vtcode".to_string()));
        let spec = CommandSpec::new("echo").with_env(env);
        let sandbox_helper = if cfg!(target_os = "linux") {
            Some(Path::new("/tmp/vtcode-test-sandbox-helper"))
        } else {
            None
        };

        let transformed = manager
            .transform(spec, &SandboxPolicy::read_only(), Path::new("/tmp"), sandbox_helper)
            .unwrap();

        assert!(transformed.sandbox_active);
        assert!(!transformed.env.contains_key("OPENAI_API_KEY"));
        assert!(!transformed.env.contains_key("LD_PRELOAD"));
        assert_eq!(transformed.env.get("SAFE_PROJECT_NAME"), Some(&"vtcode".to_string()));
    }
}