1use crate::env_helpers::default_true;
10use serde::de::{self, MapAccess, Visitor};
11use serde::{Deserialize, Deserializer, Serialize};
12use std::fmt;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16pub enum SessionPermissionMode {
17 #[default]
18 Configured,
19 FullAccess,
20}
21
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub struct SandboxConfig {
26 #[serde(skip)]
29 pub session_permission_mode: SessionPermissionMode,
30 #[serde(default = "default_false")]
32 pub enabled: bool,
33
34 #[serde(default)]
36 pub default_policy: SandboxPolicy,
37
38 #[serde(default)]
40 pub network: NetworkConfig,
41
42 #[serde(default)]
44 pub sensitive_paths: SensitivePathsConfig,
45
46 #[serde(default)]
48 pub resource_limits: ResourceLimitsConfig,
49
50 #[serde(default)]
52 pub seccomp: SeccompConfig,
53
54 #[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 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#[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 #[default]
92 ReadOnly,
93 WorkspaceWrite,
95 DangerFullAccess,
97 External,
99}
100
101#[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 #[default]
112 AllowlistOnly,
113 AllowAll,
115 BlockAll,
117}
118
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Serialize)]
122pub struct NetworkConfig {
123 pub policy: NetworkPolicy,
127
128 #[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 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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
227#[derive(Debug, Clone, Deserialize, Serialize)]
228pub struct NetworkAllowlistEntryConfig {
229 pub domain: String,
231 #[serde(default = "default_https_port")]
233 pub port: u16,
234}
235
236fn default_https_port() -> u16 {
237 443
238}
239
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242#[derive(Debug, Clone, Deserialize, Serialize)]
243pub struct SensitivePathsConfig {
244 #[serde(default = "default_true")]
246 pub use_defaults: bool,
247
248 #[serde(default)]
250 pub additional: Vec<String>,
251
252 #[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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
269#[derive(Debug, Clone, Default, Deserialize, Serialize)]
270pub struct ResourceLimitsConfig {
271 #[serde(default)]
273 pub preset: ResourceLimitsPreset,
274
275 #[serde(default)]
277 pub max_memory_mb: u64,
278
279 #[serde(default)]
281 pub max_pids: u32,
282
283 #[serde(default)]
285 pub max_disk_mb: u64,
286
287 #[serde(default)]
289 pub cpu_time_secs: u64,
290
291 #[serde(default)]
293 pub timeout_secs: u64,
294}
295
296#[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 Unlimited,
303 Conservative,
305 #[default]
307 Moderate,
308 Generous,
310 Custom,
312}
313
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[derive(Debug, Clone, Deserialize, Serialize)]
317pub struct SeccompConfig {
318 #[serde(default = "default_true")]
320 pub enabled: bool,
321
322 #[serde(default)]
324 pub profile: SeccompProfilePreset,
325
326 #[serde(default)]
328 pub additional_blocked: Vec<String>,
329
330 #[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#[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 #[default]
353 Strict,
354 Permissive,
356 Disabled,
358}
359
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
362#[derive(Debug, Clone, Default, Deserialize, Serialize)]
363pub struct ExternalSandboxConfig {
364 #[serde(default)]
366 pub sandbox_type: ExternalSandboxType,
367
368 #[serde(default)]
370 docker: DockerSandboxConfig,
371
372 #[serde(default)]
374 microvm: MicroVMSandboxConfig,
375}
376
377#[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 #[default]
384 None,
385 Docker,
387 MicroVM,
389 GVisor,
391}
392
393#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395#[derive(Debug, Clone, Deserialize, Serialize)]
396pub struct DockerSandboxConfig {
397 #[serde(default = "default_docker_image")]
399 image: String,
400
401 #[serde(default)]
403 memory_limit: String,
404
405 #[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#[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 #[default]
431 #[serde(rename = "")]
432 None,
433 Firecracker,
435 CloudHypervisor,
437 #[serde(other)]
439 Unknown,
440}
441
442impl MicroVmProvider {
443 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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
462#[derive(Debug, Clone, Deserialize, Serialize)]
463pub struct MicroVMSandboxConfig {
464 #[serde(default)]
466 vmm: MicroVmProvider,
467
468 #[serde(default)]
470 kernel_path: String,
471
472 #[serde(default)]
474 rootfs_path: String,
475
476 #[serde(default = "default_microvm_memory")]
478 memory_mb: u64,
479
480 #[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 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}