Skip to main content

vtcode_config/core/
sandbox.rs

1//! Sandbox configuration for VT Code
2//!
3//! Implements configuration for the sandbox system following the AI sandbox field guide's
4//! three-question model:
5//! - **Boundary**: What is shared between code and host
6//! - **Policy**: What can code touch (files, network, devices, syscalls)
7//! - **Lifecycle**: What survives between runs
8
9use crate::env_helpers::default_true;
10use serde::de::{self, MapAccess, Visitor};
11use serde::{Deserialize, Deserializer, Serialize};
12use std::fmt;
13
14/// Sandbox configuration
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[derive(Debug, Clone, Deserialize, Serialize)]
17pub struct SandboxConfig {
18    /// Enable sandboxing for command execution
19    #[serde(default = "default_false")]
20    pub enabled: bool,
21
22    /// Default sandbox policy
23    #[serde(default)]
24    pub default_policy: SandboxPolicy,
25
26    /// Network egress configuration
27    #[serde(default)]
28    pub network: NetworkConfig,
29
30    /// Sensitive path blocking configuration
31    #[serde(default)]
32    pub sensitive_paths: SensitivePathsConfig,
33
34    /// Resource limits configuration
35    #[serde(default)]
36    pub resource_limits: ResourceLimitsConfig,
37
38    /// Linux-specific seccomp configuration
39    #[serde(default)]
40    pub seccomp: SeccompConfig,
41
42    /// External sandbox configuration (Docker, MicroVM, etc.)
43    #[serde(default)]
44    pub external: ExternalSandboxConfig,
45}
46
47impl Default for SandboxConfig {
48    fn default() -> Self {
49        Self {
50            enabled: default_false(),
51            default_policy: SandboxPolicy::default(),
52            network: NetworkConfig::default(),
53            sensitive_paths: SensitivePathsConfig::default(),
54            resource_limits: ResourceLimitsConfig::default(),
55            seccomp: SeccompConfig::default(),
56            external: ExternalSandboxConfig::default(),
57        }
58    }
59}
60
61/// Sandbox policy following the Codex model
62#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
63#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum SandboxPolicy {
66    /// Read-only access - safest policy
67    #[default]
68    ReadOnly,
69    /// Write access within workspace only
70    WorkspaceWrite,
71    /// Full access - dangerous, requires explicit approval
72    DangerFullAccess,
73    /// External sandbox (Docker, MicroVM)
74    External,
75}
76
77/// Network egress policy
78///
79/// Replaces the legacy `allow_all`/`block_all` bool pair with a single
80/// three-state enum. Config files using the old bool fields are still accepted
81/// for backward compatibility.
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum NetworkPolicy {
86    /// Use the domain allowlist for network egress (default-deny outbound).
87    #[default]
88    AllowlistOnly,
89    /// Allow any network access (legacy `allow_all = true`).
90    AllowAll,
91    /// Block all network access, ignoring any allowlist (legacy `block_all = true`).
92    BlockAll,
93}
94
95/// Network egress configuration
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97#[derive(Debug, Clone, Serialize)]
98pub struct NetworkConfig {
99    /// Network egress policy.
100    /// Defaults to [`NetworkPolicy::AllowlistOnly`] (default-deny, then allowlist).
101    pub policy: NetworkPolicy,
102
103    /// Domain allowlist for network egress.
104    /// Following field guide: "Default-deny outbound network, then allowlist."
105    #[serde(default)]
106    pub allowlist: Vec<NetworkAllowlistEntryConfig>,
107}
108
109impl Default for NetworkConfig {
110    fn default() -> Self {
111        Self {
112            policy: NetworkPolicy::AllowlistOnly,
113            allowlist: Vec::new(),
114        }
115    }
116}
117
118impl<'de> Deserialize<'de> for NetworkConfig {
119    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
120    where
121        D: Deserializer<'de>,
122    {
123        #[derive(Deserialize)]
124        #[serde(field_identifier, rename_all = "snake_case")]
125        enum Field {
126            Policy,
127            Allowlist,
128            AllowAll,
129            BlockAll,
130        }
131
132        struct NetworkConfigVisitor;
133
134        impl<'de> Visitor<'de> for NetworkConfigVisitor {
135            type Value = NetworkConfig;
136
137            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
138                f.write_str("NetworkConfig struct")
139            }
140
141            fn visit_map<V>(self, mut map: V) -> Result<NetworkConfig, V::Error>
142            where
143                V: MapAccess<'de>,
144            {
145                let mut policy: Option<NetworkPolicy> = None;
146                let mut allowlist: Option<Vec<NetworkAllowlistEntryConfig>> = None;
147                let mut allow_all: Option<bool> = None;
148                let mut block_all: Option<bool> = None;
149
150                while let Some(key) = map.next_key()? {
151                    match key {
152                        Field::Policy => {
153                            if policy.is_some() {
154                                return Err(de::Error::duplicate_field("policy"));
155                            }
156                            policy = Some(map.next_value()?);
157                        }
158                        Field::Allowlist => {
159                            if allowlist.is_some() {
160                                return Err(de::Error::duplicate_field("allowlist"));
161                            }
162                            allowlist = Some(map.next_value()?);
163                        }
164                        Field::AllowAll => {
165                            if allow_all.is_some() {
166                                return Err(de::Error::duplicate_field("allow_all"));
167                            }
168                            allow_all = Some(map.next_value()?);
169                        }
170                        Field::BlockAll => {
171                            if block_all.is_some() {
172                                return Err(de::Error::duplicate_field("block_all"));
173                            }
174                            block_all = Some(map.next_value()?);
175                        }
176                    }
177                }
178
179                // Legacy bool fields take precedence for backward compatibility:
180                // block_all > allow_all > policy field > default (AllowlistOnly).
181                let resolved_policy = if block_all.unwrap_or(false) {
182                    NetworkPolicy::BlockAll
183                } else if allow_all.unwrap_or(false) {
184                    NetworkPolicy::AllowAll
185                } else {
186                    policy.unwrap_or_default()
187                };
188
189                Ok(NetworkConfig {
190                    policy: resolved_policy,
191                    allowlist: allowlist.unwrap_or_default(),
192                })
193            }
194        }
195
196        deserializer.deserialize_map(NetworkConfigVisitor)
197    }
198}
199
200/// Network allowlist entry
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct NetworkAllowlistEntryConfig {
204    /// Domain pattern (e.g., "api.github.com", "*.npmjs.org")
205    pub domain: String,
206    /// Port (defaults to 443)
207    #[serde(default = "default_https_port")]
208    pub port: u16,
209}
210
211fn default_https_port() -> u16 {
212    443
213}
214
215/// Sensitive paths configuration
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217#[derive(Debug, Clone, Deserialize, Serialize)]
218pub struct SensitivePathsConfig {
219    /// Use default sensitive paths (SSH, AWS, etc.)
220    #[serde(default = "default_true")]
221    pub use_defaults: bool,
222
223    /// Additional paths to block
224    #[serde(default)]
225    pub additional: Vec<String>,
226
227    /// Paths to explicitly allow (overrides defaults)
228    #[serde(default)]
229    pub exceptions: Vec<String>,
230}
231
232impl Default for SensitivePathsConfig {
233    fn default() -> Self {
234        Self {
235            use_defaults: default_true(),
236            additional: Vec::new(),
237            exceptions: Vec::new(),
238        }
239    }
240}
241
242/// Resource limits configuration
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244#[derive(Debug, Clone, Default, Deserialize, Serialize)]
245pub struct ResourceLimitsConfig {
246    /// Preset resource limits profile
247    #[serde(default)]
248    pub preset: ResourceLimitsPreset,
249
250    /// Custom memory limit in MB (0 = use preset)
251    #[serde(default)]
252    pub max_memory_mb: u64,
253
254    /// Custom max processes (0 = use preset)
255    #[serde(default)]
256    pub max_pids: u32,
257
258    /// Custom disk write limit in MB (0 = use preset)
259    #[serde(default)]
260    pub max_disk_mb: u64,
261
262    /// Custom CPU time limit in seconds (0 = use preset)
263    #[serde(default)]
264    pub cpu_time_secs: u64,
265
266    /// Custom wall clock timeout in seconds (0 = use preset)
267    #[serde(default)]
268    pub timeout_secs: u64,
269}
270
271/// Resource limits preset
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
274#[serde(rename_all = "snake_case")]
275pub enum ResourceLimitsPreset {
276    /// No limits
277    Unlimited,
278    /// Conservative limits for untrusted code
279    Conservative,
280    /// Moderate limits for semi-trusted code
281    #[default]
282    Moderate,
283    /// Generous limits for trusted code
284    Generous,
285    /// Custom limits (use individual settings)
286    Custom,
287}
288
289/// Linux seccomp configuration
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[derive(Debug, Clone, Deserialize, Serialize)]
292pub struct SeccompConfig {
293    /// Enable seccomp filtering (Linux only)
294    #[serde(default = "default_true")]
295    pub enabled: bool,
296
297    /// Seccomp profile preset
298    #[serde(default)]
299    pub profile: SeccompProfilePreset,
300
301    /// Additional syscalls to block
302    #[serde(default)]
303    pub additional_blocked: Vec<String>,
304
305    /// Log blocked syscalls instead of killing process (for debugging)
306    #[serde(default)]
307    pub log_only: bool,
308}
309
310impl Default for SeccompConfig {
311    fn default() -> Self {
312        Self {
313            enabled: default_true(),
314            profile: SeccompProfilePreset::default(),
315            additional_blocked: Vec::new(),
316            log_only: false,
317        }
318    }
319}
320
321/// Seccomp profile preset
322#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
323#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
324#[serde(rename_all = "snake_case")]
325pub enum SeccompProfilePreset {
326    /// Strict profile - blocks most dangerous syscalls
327    #[default]
328    Strict,
329    /// Permissive profile - only blocks critical syscalls
330    Permissive,
331    /// Disabled - no syscall filtering
332    Disabled,
333}
334
335/// External sandbox configuration (Docker, MicroVM)
336#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
337#[derive(Debug, Clone, Default, Deserialize, Serialize)]
338pub struct ExternalSandboxConfig {
339    /// Type of external sandbox
340    #[serde(default)]
341    pub sandbox_type: ExternalSandboxType,
342
343    /// Docker-specific settings
344    #[serde(default)]
345    docker: DockerSandboxConfig,
346
347    /// MicroVM-specific settings
348    #[serde(default)]
349    microvm: MicroVMSandboxConfig,
350}
351
352/// External sandbox type
353#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
354#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
355#[serde(rename_all = "snake_case")]
356pub enum ExternalSandboxType {
357    /// No external sandbox
358    #[default]
359    None,
360    /// Docker container
361    Docker,
362    /// MicroVM (Firecracker, cloud-hypervisor)
363    MicroVM,
364    /// gVisor container runtime
365    GVisor,
366}
367
368/// Docker sandbox configuration
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
370#[derive(Debug, Clone, Deserialize, Serialize)]
371pub struct DockerSandboxConfig {
372    /// Docker image to use
373    #[serde(default = "default_docker_image")]
374    image: String,
375
376    /// Memory limit for container
377    #[serde(default)]
378    memory_limit: String,
379
380    /// CPU limit for container
381    #[serde(default)]
382    cpu_limit: String,
383}
384
385fn default_docker_image() -> String {
386    "ubuntu:22.04".to_string()
387}
388
389impl Default for DockerSandboxConfig {
390    fn default() -> Self {
391        Self {
392            image: default_docker_image(),
393            memory_limit: String::new(),
394            cpu_limit: String::new(),
395        }
396    }
397}
398
399/// MicroVM provider (VMM) type
400#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
402#[serde(rename_all = "kebab-case")]
403pub enum MicroVmProvider {
404    /// No VMM configured
405    #[default]
406    #[serde(rename = "")]
407    None,
408    /// Firecracker VMM
409    Firecracker,
410    /// Cloud Hypervisor VMM
411    CloudHypervisor,
412    /// Forward-compatible catch-all for unknown VMM values
413    #[serde(other)]
414    Unknown,
415}
416
417impl MicroVmProvider {
418    /// Returns the string representation of this VMM provider.
419    fn as_str(&self) -> &str {
420        match self {
421            Self::None => "",
422            Self::Firecracker => "firecracker",
423            Self::CloudHypervisor => "cloud-hypervisor",
424            Self::Unknown => "unknown",
425        }
426    }
427}
428
429impl fmt::Display for MicroVmProvider {
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        f.write_str(self.as_str())
432    }
433}
434
435/// MicroVM sandbox configuration
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437#[derive(Debug, Clone, Deserialize, Serialize)]
438pub struct MicroVMSandboxConfig {
439    /// VMM to use (firecracker, cloud-hypervisor)
440    #[serde(default)]
441    vmm: MicroVmProvider,
442
443    /// Kernel image path
444    #[serde(default)]
445    kernel_path: String,
446
447    /// Root filesystem path
448    #[serde(default)]
449    rootfs_path: String,
450
451    /// Memory size in MB
452    #[serde(default = "default_microvm_memory")]
453    memory_mb: u64,
454
455    /// Number of vCPUs
456    #[serde(default = "default_vcpus")]
457    vcpus: u32,
458}
459
460fn default_microvm_memory() -> u64 {
461    512
462}
463
464fn default_vcpus() -> u32 {
465    1
466}
467
468impl Default for MicroVMSandboxConfig {
469    fn default() -> Self {
470        Self {
471            vmm: MicroVmProvider::None,
472            kernel_path: String::new(),
473            rootfs_path: String::new(),
474            memory_mb: default_microvm_memory(),
475            vcpus: default_vcpus(),
476        }
477    }
478}
479
480#[inline]
481const fn default_false() -> bool {
482    false
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_sandbox_config_default() {
491        let config = SandboxConfig::default();
492        assert!(!config.enabled);
493        assert_eq!(config.default_policy, SandboxPolicy::ReadOnly);
494    }
495
496    #[test]
497    fn test_sandbox_config_parses_default_policy() {
498        let config: SandboxConfig = toml::from_str(
499            r#"
500enabled = true
501default_policy = "workspace_write"
502"#,
503        )
504        .expect("sandbox config with default_policy should parse");
505
506        assert!(config.enabled);
507        assert_eq!(config.default_policy, SandboxPolicy::WorkspaceWrite);
508    }
509
510    #[test]
511    fn test_sandbox_config_serializes_default_policy() {
512        let config = SandboxConfig {
513            default_policy: SandboxPolicy::DangerFullAccess,
514            ..SandboxConfig::default()
515        };
516
517        let toml = toml::to_string(&config).expect("sandbox config should serialize");
518
519        assert!(toml.contains("default_policy = \"danger_full_access\""));
520        let removed_field = format!("default_{}", "mode");
521        assert!(!toml.contains(&removed_field));
522    }
523
524    #[test]
525    fn test_sandbox_config_ignores_unknown_fields_for_forward_compatibility() {
526        // Unknown fields are silently ignored so that a config written by a newer
527        // vtcode version does not break older binaries.
528        let removed_field = format!("default_{}", "mode");
529        let input = format!(
530            r#"
531enabled = true
532{removed_field} = "workspace_write"
533"#,
534        );
535        let config: SandboxConfig =
536            toml::from_str(&input).expect("sandbox config should accept unknown fields for forward compatibility");
537        assert!(config.enabled);
538    }
539
540    #[test]
541    fn test_network_config_default() {
542        let config = NetworkConfig::default();
543        assert_eq!(config.policy, NetworkPolicy::AllowlistOnly);
544        assert!(config.allowlist.is_empty());
545    }
546
547    #[test]
548    fn test_network_config_policy_field() {
549        let config: NetworkConfig = toml::from_str(r#"policy = "allow_all""#).expect("policy field should parse");
550        assert_eq!(config.policy, NetworkPolicy::AllowAll);
551    }
552
553    #[test]
554    fn test_network_config_legacy_allow_all() {
555        let config: NetworkConfig = toml::from_str(r#"allow_all = true"#).expect("legacy allow_all should parse");
556        assert_eq!(config.policy, NetworkPolicy::AllowAll);
557    }
558
559    #[test]
560    fn test_network_config_legacy_block_all() {
561        let config: NetworkConfig = toml::from_str(r#"block_all = true"#).expect("legacy block_all should parse");
562        assert_eq!(config.policy, NetworkPolicy::BlockAll);
563    }
564
565    #[test]
566    fn test_network_config_legacy_block_all_overrides_allow_all() {
567        let config: NetworkConfig = toml::from_str(
568            r#"
569allow_all = true
570block_all = true
571"#,
572        )
573        .expect("legacy bool combination should parse");
574        assert_eq!(config.policy, NetworkPolicy::BlockAll);
575    }
576
577    #[test]
578    fn test_resource_limits_config_default() {
579        let config = ResourceLimitsConfig::default();
580        assert_eq!(config.preset, ResourceLimitsPreset::Moderate);
581    }
582
583    #[test]
584    fn test_seccomp_config_default() {
585        let config = SeccompConfig::default();
586        assert!(config.enabled);
587        assert_eq!(config.profile, SeccompProfilePreset::Strict);
588    }
589}