1use crate::error::{ErrorData, Result};
11use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
12use crate::resources::ToolchainConfig;
13use crate::Platform;
14use alien_error::AlienError;
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::fmt::Debug;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase", tag = "type")]
24pub enum SandboxCode {
25 #[serde(rename_all = "camelCase")]
27 Image {
28 image: String,
33 },
34 #[serde(rename_all = "camelCase")]
36 Source {
37 src: String,
39 toolchain: ToolchainConfig,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct SandboxLimits {
53 pub cpu: String,
55 pub memory: String,
57 pub disk: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub max_processes: Option<u32>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct MicrovmTier {
75 pub baseline_memory_mib: i64,
77 pub peak_memory_mib: i64,
79 pub peak_vcpu: u32,
81 pub max_disk_mib: i64,
83}
84
85const AWS_MAX_SESSION_LIFETIME_SECONDS: u32 = 28_800;
89
90const MICROVM_TIERS: &[MicrovmTier] = &[
91 MicrovmTier {
92 baseline_memory_mib: 512,
93 peak_memory_mib: 2048,
94 peak_vcpu: 1,
95 max_disk_mib: 8192,
96 },
97 MicrovmTier {
98 baseline_memory_mib: 1024,
99 peak_memory_mib: 4096,
100 peak_vcpu: 2,
101 max_disk_mib: 8192,
102 },
103 MicrovmTier {
104 baseline_memory_mib: 2048,
105 peak_memory_mib: 8192,
106 peak_vcpu: 4,
107 max_disk_mib: 8192,
108 },
109 MicrovmTier {
110 baseline_memory_mib: 4096,
111 peak_memory_mib: 16384,
112 peak_vcpu: 8,
113 max_disk_mib: 16384,
114 },
115 MicrovmTier {
116 baseline_memory_mib: 8192,
117 peak_memory_mib: 32768,
118 peak_vcpu: 16,
119 max_disk_mib: 32768,
120 },
121];
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
126#[serde(rename_all = "camelCase", tag = "mode")]
127pub enum SandboxEgress {
128 Deny,
133 Allow,
140 #[serde(rename_all = "camelCase")]
145 AllowDomains {
146 domains: Vec<String>,
148 },
149}
150
151impl SandboxEgress {
152 pub fn internet_access_switch(&self) -> Option<bool> {
159 match self {
160 SandboxEgress::Allow => Some(true),
161 SandboxEgress::Deny => Some(false),
162 SandboxEgress::AllowDomains { .. } => None,
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct SandboxSessionPolicy {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub max_lifetime_seconds: Option<u32>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub idle_suspend_seconds: Option<u32>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct SandboxCapabilities {
194 pub files: bool,
196 pub reconnect: bool,
198 pub preview: bool,
200 pub suspend_resume: bool,
202 pub snapshot: bool,
204 pub domain_egress_rules: bool,
206 pub egress_deny: bool,
208 pub enforced_limits: bool,
210 pub process_limit: bool,
212 pub session_lifetime: bool,
214 pub supervisor_pid_namespace: bool,
220 pub supervisor_isolation: bool,
226}
227
228impl SandboxCapabilities {
229 pub fn for_platform(platform: Platform) -> Result<Self> {
235 match platform {
236 Platform::Aws => Ok(Self {
237 files: true,
238 reconnect: true,
239 preview: true,
240 suspend_resume: true,
241 snapshot: false,
242 domain_egress_rules: false,
243 egress_deny: true,
244 enforced_limits: true,
245 process_limit: false,
247 session_lifetime: true,
250 supervisor_pid_namespace: false,
255 supervisor_isolation: true,
258 }),
259 Platform::Azure => Ok(Self {
260 files: true,
261 reconnect: true,
262 preview: false,
267 suspend_resume: true,
268 snapshot: false,
274 domain_egress_rules: true,
275 egress_deny: true,
276 enforced_limits: false,
277 process_limit: false,
278 session_lifetime: false,
282 supervisor_pid_namespace: false,
284 supervisor_isolation: false,
287 }),
288 Platform::Gcp => Ok(Self::gcp_agent_platform()),
289 Platform::Kubernetes => Ok(Self {
292 files: true,
293 reconnect: true,
294 preview: false,
295 suspend_resume: false,
296 snapshot: false,
297 domain_egress_rules: false,
298 egress_deny: true,
299 enforced_limits: true,
300 process_limit: false,
302 session_lifetime: true,
304 supervisor_pid_namespace: false,
308 supervisor_isolation: false,
313 }),
314 Platform::Local => Ok(Self {
315 files: true,
316 reconnect: true,
317 preview: true,
318 suspend_resume: false,
319 snapshot: false,
320 domain_egress_rules: false,
321 egress_deny: true,
322 enforced_limits: true,
323 process_limit: true,
325 session_lifetime: false,
326 supervisor_pid_namespace: false,
329 supervisor_isolation: true,
333 }),
334 Platform::Machines | Platform::Test => {
335 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
336 platform: platform.to_string(),
337 }))
338 }
339 }
340 }
341
342 pub fn gcp_agent_platform() -> Self {
344 Self {
345 files: true,
347 reconnect: true,
351 preview: false,
353 suspend_resume: true,
355 snapshot: true,
357 domain_egress_rules: false,
359 egress_deny: true,
361 enforced_limits: true,
365 process_limit: false,
367 session_lifetime: true,
369 supervisor_pid_namespace: false,
371 supervisor_isolation: false,
374 }
375 }
376
377 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
379 let available = match capability {
380 SandboxCapability::Files => self.files,
381 SandboxCapability::Reconnect => self.reconnect,
382 SandboxCapability::Preview => self.preview,
383 SandboxCapability::SuspendResume => self.suspend_resume,
384 SandboxCapability::Snapshot => self.snapshot,
385 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
386 SandboxCapability::EgressDeny => self.egress_deny,
387 SandboxCapability::EnforcedLimits => self.enforced_limits,
388 SandboxCapability::ProcessLimit => self.process_limit,
389 SandboxCapability::SessionLifetime => self.session_lifetime,
390 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
391 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
392 };
393
394 if available {
395 return Ok(());
396 }
397
398 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
399 capability: capability.as_str().to_string(),
400 platform: platform.to_string(),
401 }))
402 }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
408#[serde(rename_all = "camelCase")]
409pub enum SandboxCapability {
410 Files,
412 Reconnect,
414 Preview,
416 SuspendResume,
418 Snapshot,
420 DomainEgressRules,
422 EgressDeny,
424 EnforcedLimits,
426 ProcessLimit,
428 SessionLifetime,
430 SupervisorPidNamespace,
432 SupervisorIsolation,
434}
435
436impl SandboxCapability {
437 pub fn as_str(&self) -> &'static str {
439 match self {
440 Self::Files => "files",
441 Self::Reconnect => "reconnect",
442 Self::Preview => "preview",
443 Self::SuspendResume => "suspendResume",
444 Self::Snapshot => "snapshot",
445 Self::DomainEgressRules => "domainEgressRules",
446 Self::EgressDeny => "egressDeny",
447 Self::EnforcedLimits => "enforcedLimits",
448 Self::ProcessLimit => "processLimit",
449 Self::SessionLifetime => "sessionLifetime",
450 Self::SupervisorPidNamespace => "supervisorPidNamespace",
451 Self::SupervisorIsolation => "supervisorIsolation",
452 }
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
458#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
459#[serde(rename_all = "camelCase", deny_unknown_fields)]
460#[builder(start_fn = new)]
461pub struct Sandbox {
462 #[builder(start_fn)]
465 pub id: String,
466 pub code: SandboxCode,
468 #[serde(skip_serializing_if = "Option::is_none")]
474 pub limits: Option<SandboxLimits>,
475 pub egress: SandboxEgress,
477 pub session: SandboxSessionPolicy,
479 #[builder(default)]
482 #[serde(default, skip_serializing_if = "Vec::is_empty")]
483 pub preview_ports: Vec<u16>,
484}
485
486pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
493 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
494}
495
496pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
503 stack.resources().any(|(_resource_id, resource)| {
504 resource
505 .config
506 .downcast_ref::<Sandbox>()
507 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
508 })
509}
510
511impl Sandbox {
512 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
514
515 pub fn id(&self) -> &str {
517 &self.id
518 }
519
520 pub fn resolved_limits(&self) -> SandboxLimits {
526 self.limits.clone().unwrap_or_else(default_limits)
527 }
528
529 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
534 let capabilities = SandboxCapabilities::for_platform(platform)?;
535
536 if let SandboxCode::Source { .. } = &self.code {
540 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
541 resource_id: self.id.clone(),
542 field: "code".to_string(),
543 value: "source".to_string(),
544 reason: "no sandbox backend builds an image from source yet; give code.image a \
545 prebuilt reference"
546 .to_string(),
547 }));
548 }
549
550 if platform == Platform::Azure {
552 self.azure_catalog_image()?;
553 }
554
555 let Some(limits) = self.limits.as_ref() else {
556 return self.validate_capabilities(&capabilities, platform);
558 };
559
560 validate_quantity(&self.id, "cpu", &limits.cpu)?;
561 validate_quantity(&self.id, "memory", &limits.memory)?;
562 validate_quantity(&self.id, "disk", &limits.disk)?;
563
564 if let Some(max_processes) = limits.max_processes {
565 if max_processes == 0 {
566 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
567 resource_id: self.id.clone(),
568 field: "maxProcesses".to_string(),
569 value: "0".to_string(),
570 reason: "a sandbox that may run no processes cannot run code".to_string(),
571 }));
572 }
573 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
574 }
575
576 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
579
580 if platform == Platform::Aws {
581 self.microvm_tier()?;
584
585 if let Some(seconds) = self.session.max_lifetime_seconds {
590 if !(1..=AWS_MAX_SESSION_LIFETIME_SECONDS).contains(&seconds) {
591 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
592 resource_id: self.id.clone(),
593 field: "maxLifetimeSeconds".to_string(),
594 value: seconds.to_string(),
595 reason: format!(
596 "AWS runs a MicroVM for between 1 and \
597 {AWS_MAX_SESSION_LIFETIME_SECONDS} seconds"
598 ),
599 }));
600 }
601 }
602 }
603
604 self.validate_capabilities(&capabilities, platform)
605 }
606
607 pub fn azure_catalog_image(&self) -> Result<&str> {
613 let refused = |value: &str, reason: &str| {
614 AlienError::new(ErrorData::SandboxLimitInvalid {
615 resource_id: self.id.clone(),
616 field: "code.image".to_string(),
617 value: value.to_string(),
618 reason: reason.to_string(),
619 })
620 };
621
622 let SandboxCode::Image { image } = &self.code else {
623 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
624 resource_id: self.id.clone(),
625 field: "code".to_string(),
626 value: "source".to_string(),
627 reason: "no sandbox backend builds an image from source yet".to_string(),
628 }));
629 };
630
631 let image = image.trim();
632 if image.is_empty() {
633 return Err(refused(image, "a sandbox has to name an image"));
634 }
635 if !image
636 .chars()
637 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
638 {
639 return Err(refused(
640 image,
641 "Azure creates a session from a public catalog disk image, so code.image must be \
642 a bare catalog name such as 'ubuntu'",
643 ));
644 }
645 Ok(image)
646 }
647
648 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
655 let Some(limits) = self.limits.as_ref() else {
656 return Ok(MICROVM_TIERS[2]);
658 };
659
660 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
661 AlienError::new(ErrorData::SandboxLimitInvalid {
662 resource_id: self.id.clone(),
663 field: "memory".to_string(),
664 value: limits.memory.clone(),
665 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
666 })
667 })?;
668 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
669 AlienError::new(ErrorData::SandboxLimitInvalid {
670 resource_id: self.id.clone(),
671 field: "disk".to_string(),
672 value: limits.disk.clone(),
673 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
674 })
675 })?;
676 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
677 AlienError::new(ErrorData::SandboxLimitInvalid {
678 resource_id: self.id.clone(),
679 field: "cpu".to_string(),
680 value: limits.cpu.clone(),
681 reason: "expected cores or millicores".to_string(),
682 })
683 })?;
684
685 let sized = |tier: &&MicrovmTier| {
690 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
691 };
692
693 let tier = MICROVM_TIERS
694 .iter()
695 .rev()
696 .find(sized)
697 .copied()
698 .ok_or_else(|| {
699 AlienError::new(ErrorData::SandboxLimitInvalid {
700 resource_id: self.id.clone(),
701 field: "memory".to_string(),
702 value: limits.memory.clone(),
703 reason: format!(
704 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
705 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
706 disk fit no size",
707 limits.memory, limits.disk
708 ),
709 })
710 })?;
711
712 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
713 if cpu_millicores < required_millicores {
714 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
715 resource_id: self.id.clone(),
716 field: "cpu".to_string(),
717 value: limits.cpu.clone(),
718 reason: format!(
719 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
720 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
721 limits.memory, tier.peak_vcpu, tier.peak_vcpu
722 ),
723 }));
724 }
725
726 Ok(tier)
727 }
728
729 fn validate_capabilities(
731 &self,
732 capabilities: &SandboxCapabilities,
733 platform: Platform,
734 ) -> Result<()> {
735 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
736 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
737 }
738
739 if let SandboxEgress::AllowDomains { domains } = &self.egress {
745 if domains.is_empty() {
746 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
747 resource_id: self.id.clone(),
748 field: "egress.domains".to_string(),
749 value: "[]".to_string(),
750 reason: "an allowlist naming no domain denies everything; declare \
751 egress: deny if that is what was meant"
752 .to_string(),
753 }));
754 }
755 }
756
757 if matches!(self.egress, SandboxEgress::Deny) {
758 capabilities.require(SandboxCapability::EgressDeny, platform)?;
759 }
760
761 if !self.preview_ports.is_empty() {
762 capabilities.require(SandboxCapability::Preview, platform)?;
763 }
764
765 if self.session.idle_suspend_seconds.is_some() {
766 capabilities.require(SandboxCapability::SuspendResume, platform)?;
767 }
768
769 if self.session.max_lifetime_seconds.is_some() {
770 capabilities.require(SandboxCapability::SessionLifetime, platform)?;
771 }
772
773 Ok(())
774 }
775}
776
777fn default_limits() -> SandboxLimits {
782 SandboxLimits {
783 cpu: "1".to_string(),
784 memory: "2Gi".to_string(),
785 disk: "8Gi".to_string(),
786 max_processes: None,
787 }
788}
789
790fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
792 let invalid = |reason: &str| {
793 AlienError::new(ErrorData::SandboxLimitInvalid {
794 resource_id: resource_id.to_string(),
795 field: field.to_string(),
796 value: value.to_string(),
797 reason: reason.to_string(),
798 })
799 };
800
801 let digits_end = value
802 .find(|c: char| !c.is_ascii_digit() && c != '.')
803 .unwrap_or(value.len());
804 let (number, suffix) = value.split_at(digits_end);
805
806 let parsed: f64 = number
807 .parse()
808 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
809
810 if parsed <= 0.0 {
811 return Err(invalid("must be greater than zero"));
812 }
813
814 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
815 if !SUFFIXES.contains(&suffix) {
816 return Err(invalid(
817 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
818 ));
819 }
820
821 Ok(())
822}
823
824fn split_quantity(value: &str) -> Option<(f64, &str)> {
826 let trimmed = value.trim();
827 let digits_end = trimmed
828 .find(|c: char| !c.is_ascii_digit() && c != '.')
829 .unwrap_or(trimmed.len());
830 let (number, suffix) = trimmed.split_at(digits_end);
831 number.parse().ok().map(|number| (number, suffix))
832}
833
834pub fn quantity_mib(value: &str) -> Option<i64> {
840 let (number, suffix) = split_quantity(value)?;
841 let bytes = match suffix {
842 "" => number,
843 "k" => number * 1e3,
844 "M" => number * 1e6,
845 "G" => number * 1e9,
846 "T" => number * 1e12,
847 "Ki" => number * 1024.0,
848 "Mi" => number * 1024.0 * 1024.0,
849 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
850 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
851 _ => return None,
853 };
854 Some((bytes / (1024.0 * 1024.0)) as i64)
855}
856
857pub fn millicores(value: &str) -> Option<i64> {
859 let (number, suffix) = split_quantity(value)?;
860 match suffix {
861 "" => Some((number * 1000.0) as i64),
862 "m" => Some(number as i64),
863 _ => None,
864 }
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
870#[serde(rename_all = "camelCase")]
871pub struct SandboxOutputs {
872 pub parent_name: String,
874 #[serde(skip_serializing_if = "Option::is_none")]
876 pub identifier: Option<String>,
877 #[serde(skip_serializing_if = "Option::is_none")]
879 pub endpoint: Option<String>,
880}
881
882impl ResourceOutputsDefinition for SandboxOutputs {
883 fn get_resource_type(&self) -> ResourceType {
884 Sandbox::RESOURCE_TYPE
885 }
886
887 fn as_any(&self) -> &dyn Any {
888 self
889 }
890
891 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
892 Box::new(self.clone())
893 }
894
895 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
896 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
897 }
898
899 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
900 serde_json::to_value(self)
901 }
902}
903
904impl ResourceDefinition for Sandbox {
905 fn get_resource_type(&self) -> ResourceType {
906 Self::RESOURCE_TYPE
907 }
908
909 fn id(&self) -> &str {
910 &self.id
911 }
912
913 fn get_dependencies(&self) -> Vec<ResourceRef> {
914 Vec::new()
915 }
916
917 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
918 let new_sandbox = new_config
919 .as_any()
920 .downcast_ref::<Sandbox>()
921 .ok_or_else(|| {
922 AlienError::new(ErrorData::UnexpectedResourceType {
923 resource_id: self.id.clone(),
924 expected: Self::RESOURCE_TYPE,
925 actual: new_config.get_resource_type(),
926 })
927 })?;
928
929 if self.id != new_sandbox.id {
930 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
931 resource_id: self.id.clone(),
932 reason: "the 'id' field is immutable".to_string(),
933 }));
934 }
935
936 Ok(())
937 }
938
939 fn as_any(&self) -> &dyn Any {
940 self
941 }
942
943 fn as_any_mut(&mut self) -> &mut dyn Any {
944 self
945 }
946
947 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
948 Box::new(self.clone())
949 }
950
951 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
952 other.as_any().downcast_ref::<Sandbox>() == Some(self)
953 }
954
955 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
956 serde_json::to_value(self)
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963
964 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
965 Sandbox::new("agent-sbx".to_string())
966 .code(SandboxCode::Image {
967 image: "ubuntu".to_string(),
968 })
969 .limits(SandboxLimits {
970 cpu: "1".to_string(),
971 memory: "2Gi".to_string(),
972 disk: "20Gi".to_string(),
973 max_processes: None,
974 })
975 .egress(egress)
976 .session(SandboxSessionPolicy {
977 max_lifetime_seconds: None,
978 idle_suspend_seconds: None,
979 })
980 .preview_ports(preview_ports)
981 .build()
982 }
983
984 #[test]
985 fn resource_type_is_stable() {
986 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
987 }
988
989 #[test]
990 fn capability_sets_are_per_platform() {
991 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
992 assert!(
993 gcp.reconnect,
994 "generation from the container boot id makes a session reachable across processes"
995 );
996 assert!(!gcp.preview);
997 assert!(gcp.enforced_limits);
998
999 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1000 assert!(azure.files, "every backend moves files");
1001 assert!(gcp.files);
1002 assert!(azure.domain_egress_rules);
1005 assert!(azure.egress_deny);
1006 assert!(!azure.enforced_limits);
1009 assert!(azure.suspend_resume);
1010 assert!(!azure.snapshot);
1014 assert!(!azure.preview);
1015
1016 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1017 assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
1018 assert!(aws.suspend_resume);
1019
1020 let k8s =
1021 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1022 assert!(
1023 !k8s.preview,
1024 "the session-scoped ingress gateway does not exist yet"
1025 );
1026 }
1027
1028 #[test]
1037 fn supervisor_isolation_is_per_platform() {
1038 let value = |platform| {
1039 SandboxCapabilities::for_platform(platform)
1040 .expect("supported")
1041 .supervisor_isolation
1042 };
1043
1044 assert!(
1045 value(Platform::Aws),
1046 "root agent setuids the command to 60000"
1047 );
1048 assert!(
1049 value(Platform::Local),
1050 "the supervisor is on the host, outside the container"
1051 );
1052 assert!(
1053 !value(Platform::Kubernetes),
1054 "a single pinned uid cannot be split"
1055 );
1056 assert!(!value(Platform::Azure), "no Alien process runs the command");
1057 assert!(
1058 !value(Platform::Gcp),
1059 "no separate supervisor identity runs the command"
1060 );
1061 }
1062
1063 #[test]
1067 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1068 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1069 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1070
1071 assert_eq!(
1072 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1073 "the older axis cannot tell them apart"
1074 );
1075 assert!(
1076 aws.supervisor_isolation,
1077 "AWS setuids the command off the supervisor"
1078 );
1079 assert!(
1080 !gcp.supervisor_isolation,
1081 "the command runs under no separate supervisor identity"
1082 );
1083 }
1084
1085 #[test]
1090 fn gcp_agent_platform_row_matches_measured_backend() {
1091 let row = SandboxCapabilities::gcp_agent_platform();
1092
1093 assert!(row.files, "agent file ops move over the session envelope");
1094 assert!(
1095 row.reconnect,
1096 "generation is derived from the container boot id, so a session is reachable across \
1097 processes"
1098 );
1099 assert!(
1100 !row.preview,
1101 "the only ingress is :execute; no port-scoped capability"
1102 );
1103 assert!(
1104 row.suspend_resume,
1105 ":pause and :resume preserve the container"
1106 );
1107 assert!(
1108 row.snapshot,
1109 "session state can be captured and restored into a new session"
1110 );
1111 assert!(
1112 !row.domain_egress_rules,
1113 "VPC and DNS peering is not a hostname allowlist"
1114 );
1115 assert!(
1116 row.egress_deny,
1117 "a declared deny blocks both egress and DNS"
1118 );
1119 assert!(
1120 row.enforced_limits,
1121 "ceilings are enforced, by terminating the session on breach"
1122 );
1123 assert!(!row.process_limit, "no process-count ceiling is observed");
1124 assert!(row.session_lifetime, "ttl maps to a session expireTime");
1125 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1126 assert!(
1127 !row.supervisor_isolation,
1128 "the command is not run under a separate supervisor identity"
1129 );
1130
1131 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1133 assert_eq!(
1134 live, row,
1135 "the Platform::Gcp arm is the Agent Platform capability row"
1136 );
1137 }
1138
1139 #[test]
1140 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1141 let error = SandboxCapabilities::for_platform(Platform::Machines)
1142 .expect_err("Machines has no sandbox backend");
1143 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1144 }
1145
1146 #[test]
1147 fn unsupported_capability_names_platform_and_capability() {
1148 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1149 let error = capabilities
1150 .require(SandboxCapability::Preview, Platform::Gcp)
1151 .expect_err("GCP has no preview");
1152
1153 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1154 let rendered = error.to_string();
1155 assert!(
1156 rendered.contains("preview"),
1157 "names the capability: {rendered}"
1158 );
1159 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1160 }
1161
1162 #[test]
1166 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1167 let sandbox = sandbox_with(
1168 SandboxEgress::AllowDomains {
1169 domains: vec!["example.com".to_string()],
1170 },
1171 vec![],
1172 );
1173
1174 for platform in [
1175 Platform::Aws,
1176 Platform::Gcp,
1177 Platform::Kubernetes,
1178 Platform::Local,
1179 ] {
1180 let error = sandbox
1181 .validate_for_platform(platform)
1182 .expect_err("only Azure expresses a hostname allowlist");
1183 assert_eq!(
1184 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1185 "on {platform:?}"
1186 );
1187 }
1188
1189 assert!(
1190 SandboxCapabilities::for_platform(Platform::Azure)
1191 .expect("supported")
1192 .domain_egress_rules,
1193 "Azure's egress policy matches on host pattern"
1194 );
1195 }
1196
1197 #[test]
1200 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1201 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1202
1203 assert!(
1206 SandboxCapabilities::for_platform(Platform::Gcp)
1207 .expect("supported")
1208 .egress_deny
1209 );
1210
1211 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1212 sandbox
1213 .validate_for_platform(platform)
1214 .expect("deny is enforced here");
1215 }
1216
1217 let egress_only = Sandbox::new("sbx".to_string())
1219 .code(SandboxCode::Image {
1220 image: "alpine".to_string(),
1221 })
1222 .egress(SandboxEgress::Deny)
1223 .session(SandboxSessionPolicy {
1224 max_lifetime_seconds: None,
1225 idle_suspend_seconds: None,
1226 })
1227 .build();
1228
1229 egress_only
1230 .validate_for_platform(Platform::Azure)
1231 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1232 }
1233
1234 #[test]
1238 fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() {
1239 let declared = sandbox_with(SandboxEgress::Deny, Vec::new());
1240 declared
1241 .validate_for_platform(Platform::Azure)
1242 .expect_err("declaring ceilings Azure cannot enforce is rejected");
1243
1244 let undeclared = Sandbox::new("sbx".to_string())
1245 .code(SandboxCode::Image {
1246 image: "alpine".to_string(),
1247 })
1248 .egress(SandboxEgress::Deny)
1249 .session(SandboxSessionPolicy {
1250 max_lifetime_seconds: None,
1251 idle_suspend_seconds: None,
1252 })
1253 .build();
1254
1255 undeclared
1256 .validate_for_platform(Platform::Azure)
1257 .expect("a sandbox naming no ceilings takes the platform's own");
1258
1259 assert_eq!(undeclared.resolved_limits().cpu, "1");
1261 }
1262
1263 #[test]
1264 fn preview_ports_require_the_preview_capability() {
1265 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1266
1267 sandbox
1268 .validate_for_platform(Platform::Aws)
1269 .expect("AWS mints a port-scoped JWE");
1270
1271 let error = sandbox
1272 .validate_for_platform(Platform::Kubernetes)
1273 .expect_err("Kubernetes preview is deferred");
1274 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1275 }
1276
1277 #[test]
1278 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1279 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1280 sandbox
1281 .validate_for_platform(Platform::Gcp)
1282 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1283 }
1284
1285 #[test]
1286 fn invalid_quantities_are_rejected_with_the_offending_field() {
1287 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1288 sandbox
1289 .limits
1290 .as_mut()
1291 .expect("the fixture declares limits")
1292 .memory = "2Gb".to_string();
1293
1294 let error = sandbox
1295 .validate_for_platform(Platform::Aws)
1296 .expect_err("Gb is not a valid suffix");
1297 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1298 assert!(error.to_string().contains("memory"));
1299
1300 sandbox
1301 .limits
1302 .as_mut()
1303 .expect("the fixture declares limits")
1304 .memory = "2Gi".to_string();
1305 sandbox
1306 .limits
1307 .as_mut()
1308 .expect("the fixture declares limits")
1309 .cpu = "0".to_string();
1310 let error = sandbox
1311 .validate_for_platform(Platform::Aws)
1312 .expect_err("zero cpu is not a ceiling");
1313 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1314 }
1315
1316 #[test]
1317 fn zero_max_processes_is_rejected() {
1318 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1319 sandbox
1320 .limits
1321 .as_mut()
1322 .expect("the fixture declares limits")
1323 .max_processes = Some(0);
1324
1325 let error = sandbox
1326 .validate_for_platform(Platform::Local)
1327 .expect_err("a sandbox must be able to run at least one process");
1328 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1329 assert!(error.to_string().contains("maxProcesses"));
1330 }
1331
1332 #[test]
1336 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1337 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1338 sandbox
1339 .limits
1340 .as_mut()
1341 .expect("the fixture declares limits")
1342 .max_processes = Some(256);
1343
1344 sandbox
1345 .validate_for_platform(Platform::Local)
1346 .expect("Docker takes a pids limit");
1347
1348 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1349 let error = sandbox
1350 .validate_for_platform(platform)
1351 .expect_err("a process ceiling nothing applies must be refused");
1352 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1353 }
1354 }
1355
1356 #[test]
1360 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1361 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1362
1363 for seconds in [0, 28_801, 100_000] {
1364 sandbox.session.max_lifetime_seconds = Some(seconds);
1365 let error = sandbox
1366 .validate_for_platform(Platform::Aws)
1367 .expect_err("a lifetime outside what AWS runs is refused");
1368 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1369
1370 sandbox
1372 .validate_for_platform(Platform::Kubernetes)
1373 .expect("the kubelet takes any activeDeadlineSeconds");
1374 }
1375
1376 sandbox.session.max_lifetime_seconds = Some(28_800);
1377 sandbox
1378 .validate_for_platform(Platform::Aws)
1379 .expect("the ceiling itself is allowed");
1380 }
1381
1382 #[test]
1387 fn an_image_azure_cannot_pull_is_refused_while_planning() {
1388 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1389 sandbox.limits = None;
1392
1393 for image in [
1394 "ubuntu:24.04",
1395 "ghcr.io/myorg/sandbox:latest",
1396 "ubuntu@sha256:abc",
1397 "",
1398 " ",
1399 "ubuntu latest",
1400 "ubuntu?x",
1401 ] {
1402 sandbox.code = SandboxCode::Image {
1403 image: image.to_string(),
1404 };
1405 let error = sandbox
1406 .validate_for_platform(Platform::Azure)
1407 .expect_err("an image Azure has nowhere to put is refused");
1408 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1409
1410 sandbox
1412 .validate_for_platform(Platform::Kubernetes)
1413 .expect("a registry reference is what every other backend takes");
1414 }
1415
1416 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1417 sandbox.code = SandboxCode::Image {
1418 image: image.to_string(),
1419 };
1420 sandbox
1421 .validate_for_platform(Platform::Azure)
1422 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1423 }
1424
1425 sandbox.code = SandboxCode::Image {
1427 image: " ubuntu ".to_string(),
1428 };
1429 assert_eq!(
1430 sandbox
1431 .azure_catalog_image()
1432 .expect("a padded name is still a name"),
1433 "ubuntu"
1434 );
1435 }
1436
1437 #[test]
1441 fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1442 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1443 sandbox.session.max_lifetime_seconds = Some(3600);
1444
1445 sandbox
1446 .validate_for_platform(Platform::Kubernetes)
1447 .expect("the kubelet enforces activeDeadlineSeconds");
1448 sandbox
1449 .validate_for_platform(Platform::Aws)
1450 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1451
1452 for platform in [Platform::Azure, Platform::Local] {
1453 let error = sandbox
1454 .validate_for_platform(platform)
1455 .expect_err("a deadline nothing applies must be refused");
1456 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1457 }
1458 }
1459
1460 #[test]
1464 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1465 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1466 let tier = sandbox
1467 .microvm_tier()
1468 .expect("2Gi/1cpu/20Gi is satisfiable");
1469
1470 assert_eq!(
1471 tier.peak_memory_mib, 2048,
1472 "the peak is the declared ceiling"
1473 );
1474 assert_eq!(
1475 tier.baseline_memory_mib, 512,
1476 "which is a quarter of it as the baseline"
1477 );
1478 assert!(tier.max_disk_mib <= 20 * 1024);
1479 }
1480
1481 #[test]
1485 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1486 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1487 {
1488 let limits = sandbox
1489 .limits
1490 .as_mut()
1491 .expect("the fixture declares limits");
1492 limits.cpu = "1".to_string();
1493 limits.memory = "8Gi".to_string();
1494 }
1495
1496 let error = sandbox
1497 .microvm_tier()
1498 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1499 assert!(
1500 error.to_string().contains("4 vCPU"),
1501 "the refusal must say what the memory ceiling implies: {error}"
1502 );
1503
1504 sandbox
1505 .limits
1506 .as_mut()
1507 .expect("the fixture declares limits")
1508 .cpu = "4".to_string();
1509 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1510 assert_eq!(tier.peak_memory_mib, 8192);
1511 }
1512
1513 #[test]
1516 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1517 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1518 sandbox
1519 .limits
1520 .as_mut()
1521 .expect("the fixture declares limits")
1522 .memory = "1Gi".to_string();
1523
1524 let error = sandbox
1525 .validate_for_platform(Platform::Aws)
1526 .expect_err("no MicroVM size peaks at or below 1Gi");
1527 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1528 assert!(
1529 error.to_string().contains("2Gi"),
1530 "the refusal must say what the smallest holdable ceiling is: {error}"
1531 );
1532 }
1533
1534 #[test]
1538 fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1539 let sandbox = Sandbox::new("agent".to_string())
1540 .code(SandboxCode::Source {
1541 src: "./sandbox".to_string(),
1542 toolchain: ToolchainConfig::Docker {
1543 dockerfile: None,
1544 build_args: None,
1545 target: None,
1546 },
1547 })
1548 .egress(SandboxEgress::Deny)
1549 .session(SandboxSessionPolicy {
1550 max_lifetime_seconds: None,
1551 idle_suspend_seconds: None,
1552 })
1553 .build();
1554
1555 for platform in [
1556 Platform::Aws,
1557 Platform::Azure,
1558 Platform::Gcp,
1559 Platform::Kubernetes,
1560 Platform::Local,
1561 ] {
1562 let error = sandbox
1563 .validate_for_platform(platform)
1564 .expect_err("no backend builds a sandbox image from source");
1565 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1566 assert!(
1567 error.to_string().contains("code.image"),
1568 "the refusal must say what to write instead: {error}"
1569 );
1570 }
1571 }
1572
1573 #[test]
1576 fn every_accepted_unit_converts_rather_than_falling_back() {
1577 assert_eq!(quantity_mib("2Gi"), Some(2048));
1578 assert_eq!(quantity_mib("512Mi"), Some(512));
1579 assert_eq!(quantity_mib("4G"), Some(3814));
1580 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1581 assert_eq!(millicores("1"), Some(1000));
1582 assert_eq!(millicores("500m"), Some(500));
1583 }
1584
1585 #[test]
1586 fn unknown_fields_are_rejected() {
1587 let json = r#"{
1588 "id": "sbx",
1589 "code": {"type": "image", "image": "ubuntu:24.04"},
1590 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1591 "egress": {"mode": "deny"},
1592 "session": {},
1593 "unexpected": true
1594 }"#;
1595
1596 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1597 }
1598
1599 #[test]
1600 fn serialization_roundtrips() {
1601 let sandbox = sandbox_with(
1602 SandboxEgress::AllowDomains {
1603 domains: vec!["example.com".to_string()],
1604 },
1605 vec![8080, 9090],
1606 );
1607
1608 let json = serde_json::to_string(&sandbox).expect("serializes");
1609 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1610 assert_eq!(sandbox, restored);
1611 }
1612
1613 #[test]
1614 fn id_is_immutable_across_updates() {
1615 let original = sandbox_with(SandboxEgress::Deny, vec![]);
1616 let renamed = Sandbox::new("other".to_string())
1617 .code(SandboxCode::Image {
1618 image: "ubuntu".to_string(),
1619 })
1620 .limits(
1621 original
1622 .limits
1623 .clone()
1624 .expect("the fixture declares limits"),
1625 )
1626 .egress(SandboxEgress::Deny)
1627 .session(SandboxSessionPolicy {
1628 max_lifetime_seconds: None,
1629 idle_suspend_seconds: None,
1630 })
1631 .build();
1632
1633 original
1634 .validate_update(&original.clone())
1635 .expect("an unchanged config is a valid update");
1636 original
1637 .validate_update(&renamed)
1638 .expect_err("renaming a sandbox is not an update");
1639 }
1640
1641 #[test]
1647 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
1648 let with_policy = |session: SandboxSessionPolicy| {
1649 Sandbox::new("sbx".to_string())
1650 .code(SandboxCode::Image {
1651 image: "ubuntu".to_string(),
1652 })
1653 .egress(SandboxEgress::Allow)
1654 .session(session)
1655 .build()
1656 .validate_for_platform(Platform::Azure)
1657 };
1658
1659 with_policy(SandboxSessionPolicy {
1660 max_lifetime_seconds: None,
1661 idle_suspend_seconds: Some(900),
1662 })
1663 .expect("Azure suspends a session on idle");
1664
1665 let error = with_policy(SandboxSessionPolicy {
1666 max_lifetime_seconds: Some(3600),
1667 idle_suspend_seconds: None,
1668 })
1669 .expect_err("Azure has no wall-clock ceiling to enforce one with");
1670 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1671 assert!(
1672 error.message.contains("sessionLifetime"),
1673 "names the capability: {}",
1674 error.message
1675 );
1676 }
1677
1678 #[test]
1684 fn an_allowlist_with_no_domains_is_refused() {
1685 let declared = |domains: Vec<String>| {
1686 Sandbox::new("sbx".to_string())
1687 .code(SandboxCode::Image {
1688 image: "ubuntu".to_string(),
1689 })
1690 .egress(SandboxEgress::AllowDomains { domains })
1691 .session(SandboxSessionPolicy {
1692 max_lifetime_seconds: None,
1693 idle_suspend_seconds: None,
1694 })
1695 .build()
1696 .validate_for_platform(Platform::Azure)
1697 };
1698
1699 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
1700 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1701
1702 declared(vec!["api.example.com".to_string()])
1703 .expect("a named domain is what an allowlist is for");
1704 }
1705
1706 #[test]
1709 fn internet_access_switch_maps_only_the_two_expressible_modes() {
1710 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
1711 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
1712 assert_eq!(
1713 SandboxEgress::AllowDomains {
1714 domains: vec!["api.example.com".to_string()]
1715 }
1716 .internet_access_switch(),
1717 None,
1718 "a host list has no boolean and must not be approximated"
1719 );
1720 }
1721}