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