Skip to main content

alien_core/resources/
sandbox.rs

1//! Sandbox resource for running untrusted code in an isolated environment.
2//!
3//! A Sandbox is a session-oriented resource: the declaration provisions a durable parent, and
4//! the application creates and destroys individual sessions through its binding at runtime.
5//!
6//! The capability set differs per platform and is published rather than assumed. Calling an
7//! unsupported capability is a typed error naming both the platform and the capability, so a
8//! portable application can branch on `SandboxCapabilities` before it calls.
9
10use 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/// Specifies where the sandbox's root filesystem comes from.
21#[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    /// A prebuilt container image used as the sandbox root filesystem.
26    #[serde(rename_all = "camelCase")]
27    Image {
28        /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).
29        ///
30        /// Two backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a
31        /// bare catalog name such as `ubuntu`. Each refuses the other's shape while planning.
32        image: String,
33    },
34    /// Source built into a sandbox image at deploy time.
35    #[serde(rename_all = "camelCase")]
36    Source {
37        /// The source directory to build from
38        src: String,
39        /// Toolchain configuration with type-safe options
40        toolchain: ToolchainConfig,
41    },
42}
43
44/// Hard ceilings enforced on a sandbox session.
45///
46/// These are limits, not scheduling requests. Untrusted code does not respect a hint, so every
47/// field is enforced by the platform and a platform that cannot enforce one is rejected at plan
48/// time rather than silently ignoring it.
49#[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    /// CPU ceiling in cores or millicores (e.g. `"1"`, `"500m"`)
54    pub cpu: String,
55    /// Memory ceiling (e.g. `"2Gi"`, `"512Mi"`)
56    pub memory: String,
57    /// Disk ceiling (e.g. `"20Gi"`)
58    pub disk: String,
59    /// Maximum number of processes, which bounds fork bombs.
60    ///
61    /// Optional because only a container runtime has the primitive: Kubernetes sets a pid ceiling
62    /// per node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring
63    /// it on a platform that cannot apply it is refused at plan time.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub max_processes: Option<u32>,
66}
67
68/// One of the five sizes a Lambda MicroVM can be built at.
69///
70/// AWS has no ceiling knob: `minimumMemoryInMiB` sets a *baseline* and a running MicroVM bursts
71/// vertically to four times it with no way to opt out. A declared ceiling is therefore honoured by
72/// picking the tier whose **peak** stays inside it, not the tier whose baseline matches it.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct MicrovmTier {
75    /// What `minimumMemoryInMiB` is set to.
76    pub baseline_memory_mib: i64,
77    /// The most memory the MicroVM can reach, in MiB.
78    pub peak_memory_mib: i64,
79    /// The most vCPU the MicroVM can reach.
80    pub peak_vcpu: u32,
81    /// The most disk the MicroVM can use, in MiB.
82    pub max_disk_mib: i64,
83}
84
85/// The published sizes, smallest first. Baseline memory to vCPU is 2 GB per vCPU, peak is four
86/// times baseline, and disk is fixed per tier rather than independently selectable.
87/// Longest life AWS will run a MicroVM for, from `RunMicrovm`'s `maximumDurationInSeconds`.
88const 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/// Outbound network policy for a sandbox.
124#[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    /// No outbound network access.
129    ///
130    /// Routed traffic only. Link-local is not outbound and no backend's egress control reaches
131    /// it, so this is not a boundary against instance metadata.
132    Deny,
133    /// Unrestricted outbound access to the public internet, and none to private ranges or the
134    /// deployment's own network.
135    ///
136    /// Link-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.
137    /// Azure and GCP deliver the first only: one matches host patterns and the other is a single
138    /// switch, so neither can name an address range to exclude.
139    Allow,
140    /// Outbound access only to the listed hostnames.
141    ///
142    /// Azure alone expresses it: its egress proxy matches on host pattern. The others filter by
143    /// CIDR or carry a single switch, and both would approximate the list rather than keep it.
144    #[serde(rename_all = "camelCase")]
145    AllowDomains {
146        /// Hostnames the sandbox may reach
147        domains: Vec<String>,
148    },
149}
150
151impl SandboxEgress {
152    /// The single outbound switch for a backend that has no host matcher, or `None` for a mode a
153    /// boolean cannot carry.
154    ///
155    /// `AllowDomains` needs a host list, so it maps to nothing and each caller refuses it in its
156    /// own error naming the sandbox. One source for what a mode means, so a template and a session
157    /// cannot disagree on it.
158    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/// How long a session may live and when it is suspended.
168#[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    /// Wall-clock ceiling on a single session, after which the platform terminates it.
173    ///
174    /// Optional because not every backend has the primitive: Kubernetes has
175    /// `activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local
176    /// expose one, so declaring a ceiling there is refused at plan time rather than accepted and
177    /// never applied. AWS caps it at 8 hours.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub max_lifetime_seconds: Option<u32>,
180    /// Idle period after which the session is suspended, where the platform supports it
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub idle_suspend_seconds: Option<u32>,
183}
184
185/// What a platform's sandbox backend can actually do.
186///
187/// Published so portable code can branch before calling rather than discovering a gap through
188/// an error. Every field here corresponds to a capability that at least one platform lacks;
189/// create, exec and terminate are the guaranteed floor and are therefore not listed.
190#[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    /// Files can be moved in and out of a session
195    pub files: bool,
196    /// A later call can reach a session created by an earlier one
197    pub reconnect: bool,
198    /// An authenticated, port-scoped capability to reach a service inside the sandbox
199    pub preview: bool,
200    /// Session state can be suspended and resumed
201    pub suspend_resume: bool,
202    /// A session's full state can be captured and used to create another
203    pub snapshot: bool,
204    /// Egress can be restricted to a hostname allowlist
205    pub domain_egress_rules: bool,
206    /// Whether a declared `deny` is actually enforced, rather than accepted and dropped
207    pub egress_deny: bool,
208    /// The platform enforces the declared cpu, memory and disk ceilings
209    pub enforced_limits: bool,
210    /// The platform can cap how many processes a session runs
211    pub process_limit: bool,
212    /// The platform terminates a session at a declared wall-clock deadline
213    pub session_lifetime: bool,
214    /// A command runs in its own PID namespace and cannot see or signal the agent's processes.
215    ///
216    /// Only where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the
217    /// Kubernetes sandbox pod drops every capability — which is also what denies `ptrace` by
218    /// construction, so granting it there would remove a lock to add one.
219    pub supervisor_pid_namespace: bool,
220    /// The process supervising a command is a different identity from the command.
221    ///
222    /// False where a command runs as the agent's own user: it can then read the supervisor's
223    /// environment and signal it. Separate from `supervisorPidNamespace`, which is about
224    /// visibility rather than identity — a backend can have one without the other.
225    pub supervisor_isolation: bool,
226}
227
228impl SandboxCapabilities {
229    /// Returns what the given platform's sandbox backend supports.
230    ///
231    /// Errors for platforms with no sandbox backend, rather than returning an all-false set —
232    /// "every capability is missing" and "this platform has no sandboxes" are different
233    /// conditions and an application should not have to tell them apart by inspection.
234    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                // Nothing in the API bounds process count.
246                process_limit: false,
247                // `maximumDurationInSeconds` on `RunMicrovm`, which Lambda enforces by
248                // terminating the MicroVM. Capped at 8 hours by the service.
249                session_lifetime: true,
250                // Measured, not assumed: the agent inside a Lambda MicroVM runs as uid 0 with
251                // `CapEff: 00000000a80425fb`, the standard container default set, which excludes
252                // `CAP_SYS_ADMIN`. It can drop privilege (`CAP_SETUID`/`CAP_SETGID` are held) and
253                // it cannot create a namespace. No backend offers this today.
254                supervisor_pid_namespace: false,
255                // The agent runs as uid 0 and `setuid`s the command to uid 60000, so the command
256                // runs under a different identity than the process supervising it.
257                supervisor_isolation: true,
258            }),
259            Platform::Azure => Ok(Self {
260                files: true,
261                reconnect: true,
262                // A sandbox port carries a URL and an auth config, and the auth config offers two
263                // things: anonymous, or Entra ID with an allowlist of human email addresses.
264                // Neither is a credential scoped to a port for a fixed time, which is what a
265                // preview capability is. Returning the anonymous URL would publish the port.
266                preview: false,
267                suspend_resume: true,
268                // The one cloud of the five that could offer this, and the blocker is ours:
269                // `snapshot()` returns an id and `CreateSessionRequest` has no field to consume
270                // one, so no backend can complete the round trip. Nothing in the resource model
271                // owns such an artifact either, and Microsoft states snapshots are not garbage
272                // collected — an id with no owner is a bill that grows.
273                snapshot: false,
274                domain_egress_rules: true,
275                egress_deny: true,
276                enforced_limits: false,
277                process_limit: false,
278                // Auto-suspend and auto-delete exist; a wall-clock ceiling does not. Accepting
279                // `maxLifetimeSeconds` here would be the silent no-op the capability set exists
280                // to prevent, so this is a decision rather than a gap.
281                session_lifetime: false,
282                // No Alien process inside an Azure sandbox, so there is no supervisor to isolate.
283                supervisor_pid_namespace: false,
284                // No Alien process runs the command at all — the platform's own data plane does,
285                // so there is no separate supervisor identity to speak of.
286                supervisor_isolation: false,
287            }),
288            Platform::Gcp => Ok(Self::gcp_agent_platform()),
289            // Preview needs a gateway that validates a session-and-port capability, and that
290            // gateway does not exist yet.
291            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                // A pid ceiling is a kubelet setting per node, not a pod field.
301                process_limit: false,
302                // `activeDeadlineSeconds` on the pod, which the kubelet enforces.
303                session_lifetime: true,
304                // The pod drops every capability, including the `CAP_SYS_ADMIN` the agent would
305                // need to unshare. That is also what denies `ptrace`, so this stays false rather
306                // than the pod being weakened to make it true.
307                supervisor_pid_namespace: false,
308                // The pod pins one uid (`run_as_user: 65534` on both pod and container) with
309                // `capabilities.drop: [ALL]` and `allow_privilege_escalation: false`, so no
310                // process can setuid to split the command off from a supervisor. No uid split is
311                // possible, so none exists.
312                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                // Docker's `--pids-limit`.
324                process_limit: true,
325                session_lifetime: false,
326                // Local has no in-sandbox agent: the manager drives Docker from outside, so
327                // there is no supervisor inside the sandbox to isolate from.
328                supervisor_pid_namespace: false,
329                // The supervisor is the manager on the host, outside the container entirely, and
330                // `docker exec` runs the command as the workload uid — a different identity by
331                // construction.
332                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    /// What the GCP Agent Platform sandbox backend supports; the body of the `Platform::Gcp` arm.
343    pub fn gcp_agent_platform() -> Self {
344        Self {
345            // Agent file operations move over the session envelope.
346            files: true,
347            // Reaching a session across processes is safe because `generation` is derived from the
348            // container boot id read through the agent's health op, so a caller detects a container
349            // replaced under a stable session name rather than reconnecting to a blank one.
350            reconnect: true,
351            // No method mints a port-scoped ingress capability; the only ingress is `:execute`.
352            preview: false,
353            // `:pause` and `:resume` preserve the running container.
354            suspend_resume: true,
355            // A session's state can be captured and used to create another.
356            snapshot: true,
357            // Egress is shaped by VPC and DNS peering, which is not a hostname allowlist.
358            domain_egress_rules: false,
359            // A declared `deny` blocks both routed egress and DNS.
360            egress_deny: true,
361            // The declared ceilings are enforced, but by terminating the session on breach rather
362            // than by refusing the allocation — a caller reading `true` should expect the session
363            // to die, not a clean error at the point of the request.
364            enforced_limits: true,
365            // No ceiling on process count is observed.
366            process_limit: false,
367            // `ttl` maps to a session `expireTime` the platform terminates at.
368            session_lifetime: true,
369            // No PID-namespace isolation between the command and anything supervising it.
370            supervisor_pid_namespace: false,
371            // No separate supervisor identity: the command is not run under a different identity
372            // than the process supervising it.
373            supervisor_isolation: false,
374        }
375    }
376
377    /// Returns a typed error if the named capability is absent on this platform.
378    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/// Names a single sandbox capability, so an unsupported call can report which one it needed.
406#[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    /// Moving files in and out of a session
411    Files,
412    /// Reaching a session created by an earlier call
413    Reconnect,
414    /// An authenticated, port-scoped ingress capability
415    Preview,
416    /// Suspending and resuming session state
417    SuspendResume,
418    /// Capturing full session state
419    Snapshot,
420    /// Restricting egress to a hostname allowlist
421    DomainEgressRules,
422    /// Refusing outbound access when a sandbox declares none
423    EgressDeny,
424    /// Platform-enforced resource ceilings
425    EnforcedLimits,
426    /// A ceiling on the number of processes a session may run
427    ProcessLimit,
428    /// A wall-clock ceiling on a session, applied by the platform rather than by a caller
429    SessionLifetime,
430    /// A command runs in its own PID namespace, isolated from the agent supervising it
431    SupervisorPidNamespace,
432    /// A command runs under a different identity than the process supervising it
433    SupervisorIsolation,
434}
435
436impl SandboxCapability {
437    /// Returns the stable identifier used in errors and capability queries.
438    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/// An isolated environment for running untrusted code, created per session at runtime.
457#[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    /// Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and
463    /// underscores ([A-Za-z0-9-_]). Maximum 64 characters.
464    #[builder(start_fn)]
465    pub id: String,
466    /// Where the sandbox's root filesystem comes from
467    pub code: SandboxCode,
468    /// Enforced resource ceilings.
469    ///
470    /// Optional because not every platform can enforce them, and a declaration that names none
471    /// takes the platform's own defaults. Naming them on a platform that cannot enforce them is
472    /// rejected at plan time rather than silently ignored.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub limits: Option<SandboxLimits>,
475    /// Outbound network policy
476    pub egress: SandboxEgress,
477    /// Session lifetime and idle behaviour
478    pub session: SandboxSessionPolicy,
479    /// Ports eligible for a preview capability. An application reaches its sandbox through the
480    /// provider, so it cannot widen its own ingress at runtime; a holder of a remote binding's
481    /// credentials is bounded by no port condition, which is why a remote sandbox declares none.
482    #[builder(default)]
483    #[serde(default, skip_serializing_if = "Vec::is_empty")]
484    pub preview_ports: Vec<u16>,
485}
486
487/// Whether the artifact being rendered restricts which network modes it accepts.
488///
489/// A sandbox is not emitted on a Kubernetes target, so nothing there routes egress through a
490/// connector and the default network stays a working answer. Every site that withholds the mode,
491/// explains the restriction, or renders a branch for it has to ask this one question — asking the
492/// stack directly is how they came to disagree.
493pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
494    !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
495}
496
497/// Whether any sandbox in the stack forces setup to name subnets.
498///
499/// A restricted sandbox routes session egress through a VPC connector, and neither generator can
500/// enumerate the account default VPC's subnets, so that mode leaves the connector without any and
501/// it fails at create. Callers that render an artifact want [`restricts_network_mode`] instead:
502/// this one answers for the declaration, which on a Kubernetes target is not what gets emitted.
503pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
504    stack.resources().any(|(_resource_id, resource)| {
505        resource
506            .config
507            .downcast_ref::<Sandbox>()
508            .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
509    })
510}
511
512impl Sandbox {
513    /// The resource type identifier for Sandbox
514    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
515
516    /// Returns the sandbox's unique identifier.
517    pub fn id(&self) -> &str {
518        &self.id
519    }
520
521    /// The declared ceilings, or the defaults a platform applies when none were named.
522    ///
523    /// Backends want a concrete set: a sandbox with no declared ceilings still runs inside
524    /// whatever the platform gives it, and a backend that had to branch on `None` would end up
525    /// inventing its own default anyway.
526    pub fn resolved_limits(&self) -> SandboxLimits {
527        self.limits.clone().unwrap_or_else(default_limits)
528    }
529
530    /// Validates the declaration against what the target platform can enforce.
531    ///
532    /// Runs at plan time so an unenforceable limit or an unsupported egress mode fails before
533    /// anything is provisioned, rather than at the first exec.
534    pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
535        let capabilities = SandboxCapabilities::for_platform(platform)?;
536
537        // No backend builds a sandbox image from source: an empty image string schedules a pod
538        // that can never run, the silent no-op the capability contract forbids — the failure
539        // has to land here instead.
540        if let SandboxCode::Source { .. } = &self.code {
541            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
542                resource_id: self.id.clone(),
543                field: "code".to_string(),
544                value: "source".to_string(),
545                reason: "no sandbox backend builds an image from source yet; give code.image a \
546                         prebuilt reference"
547                    .to_string(),
548            }));
549        }
550
551        // Read before the limits, because the image is declared whether or not any are.
552        if platform == Platform::Azure {
553            self.azure_catalog_image()?;
554        }
555
556        let Some(limits) = self.limits.as_ref() else {
557            // Nothing declared, so nothing to enforce and nothing to reject.
558            return self.validate_capabilities(&capabilities, platform);
559        };
560
561        validate_quantity(&self.id, "cpu", &limits.cpu)?;
562        validate_quantity(&self.id, "memory", &limits.memory)?;
563        validate_quantity(&self.id, "disk", &limits.disk)?;
564
565        if let Some(max_processes) = limits.max_processes {
566            if max_processes == 0 {
567                return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
568                    resource_id: self.id.clone(),
569                    field: "maxProcesses".to_string(),
570                    value: "0".to_string(),
571                    reason: "a sandbox that may run no processes cannot run code".to_string(),
572                }));
573            }
574            capabilities.require(SandboxCapability::ProcessLimit, platform)?;
575        }
576
577        // Declaring limits a platform ignores is worse than not declaring them: the stack reads
578        // as bounded while the sandbox is not.
579        capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
580
581        if platform == Platform::Aws {
582            // Refused here rather than at emit so a customer sees it while planning, and so both
583            // package formats inherit the same answer.
584            self.microvm_tier()?;
585
586            // The ceiling is Lambda's, and it rejects the run rather than clamping — so a value
587            // outside it would pass planning, render into the package, and fail at the first
588            // session. Kubernetes takes the same field with no such bound, which is why this
589            // sits under the AWS gate rather than on the type.
590            if let Some(seconds) = self.session.max_lifetime_seconds {
591                if !(1..=AWS_MAX_SESSION_LIFETIME_SECONDS).contains(&seconds) {
592                    return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
593                        resource_id: self.id.clone(),
594                        field: "maxLifetimeSeconds".to_string(),
595                        value: seconds.to_string(),
596                        reason: format!(
597                            "AWS runs a MicroVM for between 1 and \
598                             {AWS_MAX_SESSION_LIFETIME_SECONDS} seconds"
599                        ),
600                    }));
601                }
602            }
603        }
604
605        self.validate_capabilities(&capabilities, platform)
606    }
607
608    /// The catalog disk image Azure creates a session from.
609    ///
610    /// Azure names a public catalog entry rather than pulling a reference, so a registry path,
611    /// tag or digest has nowhere to go. An allowlist, because the answer to "what else could be
612    /// in there" is a name the data plane rejects at the first session, long after the apply.
613    pub fn azure_catalog_image(&self) -> Result<&str> {
614        let refused = |value: &str, reason: &str| {
615            AlienError::new(ErrorData::SandboxLimitInvalid {
616                resource_id: self.id.clone(),
617                field: "code.image".to_string(),
618                value: value.to_string(),
619                reason: reason.to_string(),
620            })
621        };
622
623        let SandboxCode::Image { image } = &self.code else {
624            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
625                resource_id: self.id.clone(),
626                field: "code".to_string(),
627                value: "source".to_string(),
628                reason: "no sandbox backend builds an image from source yet".to_string(),
629            }));
630        };
631
632        let image = image.trim();
633        if image.is_empty() {
634            return Err(refused(image, "a sandbox has to name an image"));
635        }
636        if !image
637            .chars()
638            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
639        {
640            return Err(refused(
641                image,
642                "Azure creates a session from a public catalog disk image, so code.image must be \
643                 a bare catalog name such as 'ubuntu'",
644            ));
645        }
646        Ok(image)
647    }
648
649    /// The MicroVM size that keeps every declared ceiling, or why none does.
650    ///
651    /// AWS sizes are discrete and a running MicroVM bursts to four times its baseline, so the
652    /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier
653    /// satisfies is refused: shipping the nearest size would give the customer a sandbox that
654    /// exceeds the bound they wrote down.
655    pub fn microvm_tier(&self) -> Result<MicrovmTier> {
656        let Some(limits) = self.limits.as_ref() else {
657            // Nothing declared: AWS's own default baseline, which is also `default_limits`.
658            return Ok(MICROVM_TIERS[2]);
659        };
660
661        let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
662            AlienError::new(ErrorData::SandboxLimitInvalid {
663                resource_id: self.id.clone(),
664                field: "memory".to_string(),
665                value: limits.memory.clone(),
666                reason: "AWS sizes a MicroVM in whole MiB".to_string(),
667            })
668        })?;
669        let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
670            AlienError::new(ErrorData::SandboxLimitInvalid {
671                resource_id: self.id.clone(),
672                field: "disk".to_string(),
673                value: limits.disk.clone(),
674                reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
675            })
676        })?;
677        let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
678            AlienError::new(ErrorData::SandboxLimitInvalid {
679                resource_id: self.id.clone(),
680                field: "cpu".to_string(),
681                value: limits.cpu.clone(),
682                reason: "expected cores or millicores".to_string(),
683            })
684        })?;
685
686        // Memory and disk choose the size; cpu is then checked rather than used to choose.
687        // AWS couples cpu to memory at 2 GB per vCPU, so letting a low cpu ceiling select the
688        // size too would quietly hand back a machine four times smaller than the memory ceiling
689        // asked for, with nothing to indicate it.
690        let sized = |tier: &&MicrovmTier| {
691            tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
692        };
693
694        let tier = MICROVM_TIERS
695            .iter()
696            .rev()
697            .find(sized)
698            .copied()
699            .ok_or_else(|| {
700                AlienError::new(ErrorData::SandboxLimitInvalid {
701                    resource_id: self.id.clone(),
702                    field: "memory".to_string(),
703                    value: limits.memory.clone(),
704                    reason: format!(
705                        "a Lambda MicroVM bursts to four times its baseline, so the smallest \
706                         ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
707                         disk fit no size",
708                        limits.memory, limits.disk
709                    ),
710                })
711            })?;
712
713        let required_millicores = i64::from(tier.peak_vcpu) * 1000;
714        if cpu_millicores < required_millicores {
715            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
716                resource_id: self.id.clone(),
717                field: "cpu".to_string(),
718                value: limits.cpu.clone(),
719                reason: format!(
720                    "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
721                     reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
722                    limits.memory, tier.peak_vcpu, tier.peak_vcpu
723                ),
724            }));
725        }
726
727        Ok(tier)
728    }
729
730    /// The capability checks that do not depend on declared limits.
731    fn validate_capabilities(
732        &self,
733        capabilities: &SandboxCapabilities,
734        platform: Platform,
735    ) -> Result<()> {
736        if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
737            capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
738        }
739
740        // `allow` asks for no restriction, so a backend that ignores it fails loudly on the first
741        // blocked connection. `deny` asks for one, and a backend that ignores it puts untrusted
742        // code on the internet with nothing to notice — so only this direction is gated.
743        // An empty list is not a restriction anyone wrote down: it renders as a deny-all wearing
744        // an allowlist's label, which reads at a glance as the opposite of what it does.
745        if let SandboxEgress::AllowDomains { domains } = &self.egress {
746            if domains.is_empty() {
747                return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
748                    resource_id: self.id.clone(),
749                    field: "egress.domains".to_string(),
750                    value: "[]".to_string(),
751                    reason: "an allowlist naming no domain denies everything; declare \
752                             egress: deny if that is what was meant"
753                        .to_string(),
754                }));
755            }
756        }
757
758        if matches!(self.egress, SandboxEgress::Deny) {
759            capabilities.require(SandboxCapability::EgressDeny, platform)?;
760        }
761
762        if !self.preview_ports.is_empty() {
763            capabilities.require(SandboxCapability::Preview, platform)?;
764        }
765
766        if self.session.idle_suspend_seconds.is_some() {
767            capabilities.require(SandboxCapability::SuspendResume, platform)?;
768        }
769
770        if self.session.max_lifetime_seconds.is_some() {
771            capabilities.require(SandboxCapability::SessionLifetime, platform)?;
772        }
773
774        Ok(())
775    }
776}
777
778/// Ceilings applied when a declaration names none.
779///
780/// Modest on purpose: an undeclared sandbox is one whose author did not think about sizing, and
781/// the safe reading of that is a small box rather than a generous one.
782fn default_limits() -> SandboxLimits {
783    SandboxLimits {
784        cpu: "1".to_string(),
785        memory: "2Gi".to_string(),
786        disk: "8Gi".to_string(),
787        max_processes: None,
788    }
789}
790
791/// Validates a Kubernetes-style resource quantity such as `500m`, `2Gi` or `1`.
792fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
793    let invalid = |reason: &str| {
794        AlienError::new(ErrorData::SandboxLimitInvalid {
795            resource_id: resource_id.to_string(),
796            field: field.to_string(),
797            value: value.to_string(),
798            reason: reason.to_string(),
799        })
800    };
801
802    let digits_end = value
803        .find(|c: char| !c.is_ascii_digit() && c != '.')
804        .unwrap_or(value.len());
805    let (number, suffix) = value.split_at(digits_end);
806
807    let parsed: f64 = number
808        .parse()
809        .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
810
811    if parsed <= 0.0 {
812        return Err(invalid("must be greater than zero"));
813    }
814
815    const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
816    if !SUFFIXES.contains(&suffix) {
817        return Err(invalid(
818            "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
819        ));
820    }
821
822    Ok(())
823}
824
825/// Splits a quantity into its number and unit suffix.
826fn split_quantity(value: &str) -> Option<(f64, &str)> {
827    let trimmed = value.trim();
828    let digits_end = trimmed
829        .find(|c: char| !c.is_ascii_digit() && c != '.')
830        .unwrap_or(trimmed.len());
831    let (number, suffix) = trimmed.split_at(digits_end);
832    number.parse().ok().map(|number| (number, suffix))
833}
834
835/// A memory or disk quantity in whole MiB, rounded down.
836///
837/// Every suffix `validate_quantity` accepts is handled here. Reading only `Gi` and `Mi` and
838/// falling back for the rest would turn a declared `4G` into a different size than the customer
839/// asked for, which for a ceiling means a sandbox larger than its bound.
840pub fn quantity_mib(value: &str) -> Option<i64> {
841    let (number, suffix) = split_quantity(value)?;
842    let bytes = match suffix {
843        "" => number,
844        "k" => number * 1e3,
845        "M" => number * 1e6,
846        "G" => number * 1e9,
847        "T" => number * 1e12,
848        "Ki" => number * 1024.0,
849        "Mi" => number * 1024.0 * 1024.0,
850        "Gi" => number * 1024.0 * 1024.0 * 1024.0,
851        "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
852        // `m` is a millicore suffix; memory has no use for it.
853        _ => return None,
854    };
855    Some((bytes / (1024.0 * 1024.0)) as i64)
856}
857
858/// A CPU quantity in millicores.
859pub fn millicores(value: &str) -> Option<i64> {
860    let (number, suffix) = split_quantity(value)?;
861    match suffix {
862        "" => Some((number * 1000.0) as i64),
863        "m" => Some(number as i64),
864        _ => None,
865    }
866}
867
868/// Outputs generated by a successfully provisioned Sandbox parent.
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
871#[serde(rename_all = "camelCase")]
872pub struct SandboxOutputs {
873    /// Name of the durable parent that sessions are created inside
874    pub parent_name: String,
875    /// Platform-specific identifier for the parent (image ARN, sandbox group id, namespace)
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub identifier: Option<String>,
878    /// Data-plane endpoint sessions are created through, where the platform has one
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub endpoint: Option<String>,
881}
882
883impl ResourceOutputsDefinition for SandboxOutputs {
884    fn get_resource_type(&self) -> ResourceType {
885        Sandbox::RESOURCE_TYPE
886    }
887
888    fn as_any(&self) -> &dyn Any {
889        self
890    }
891
892    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
893        Box::new(self.clone())
894    }
895
896    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
897        other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
898    }
899
900    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
901        serde_json::to_value(self)
902    }
903}
904
905impl ResourceDefinition for Sandbox {
906    fn get_resource_type(&self) -> ResourceType {
907        Self::RESOURCE_TYPE
908    }
909
910    fn id(&self) -> &str {
911        &self.id
912    }
913
914    fn get_dependencies(&self) -> Vec<ResourceRef> {
915        Vec::new()
916    }
917
918    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
919        let new_sandbox = new_config
920            .as_any()
921            .downcast_ref::<Sandbox>()
922            .ok_or_else(|| {
923                AlienError::new(ErrorData::UnexpectedResourceType {
924                    resource_id: self.id.clone(),
925                    expected: Self::RESOURCE_TYPE,
926                    actual: new_config.get_resource_type(),
927                })
928            })?;
929
930        if self.id != new_sandbox.id {
931            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
932                resource_id: self.id.clone(),
933                reason: "the 'id' field is immutable".to_string(),
934            }));
935        }
936
937        Ok(())
938    }
939
940    fn as_any(&self) -> &dyn Any {
941        self
942    }
943
944    fn as_any_mut(&mut self) -> &mut dyn Any {
945        self
946    }
947
948    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
949        Box::new(self.clone())
950    }
951
952    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
953        other.as_any().downcast_ref::<Sandbox>() == Some(self)
954    }
955
956    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
957        serde_json::to_value(self)
958    }
959}
960
961/// The one token a sandbox bundle URI may carry, replaced with the deploying region.
962///
963/// AWS builds a MicroVM image only from a bucket in the image's own region, so a vendor
964/// publishing to every supported region needs one stored URI that resolves per region.
965pub const BUNDLE_REGION_TOKEN: &str = "{region}";
966
967/// A bundle URI split around its region token, or carried whole when it has none.
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969pub enum BundleUri<'a> {
970    /// No token: emitted exactly as it is today.
971    Literal(&'a str),
972    /// The text either side of the token, for an emitter to rejoin around its own region
973    /// expression.
974    Regional { before: &'a str, after: &'a str },
975}
976
977/// Reads a sandbox bundle URI, refusing anything an image build would only reject later.
978///
979/// The token is accepted in the bucket alone. A key-position token would name an object that does
980/// not exist, and any other brace is a typo that would otherwise reach S3 verbatim and fail ~160s
981/// into the build — which is the failure this whole check exists to move to plan time.
982pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
983    let path = uri
984        .strip_prefix("s3://")
985        .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
986    let (bucket, key) = path
987        .split_once('/')
988        .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
989
990    if key.contains('{') || key.contains('}') {
991        return Err(format!(
992            "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
993             bucket name alone"
994        ));
995    }
996
997    let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
998        if bucket.contains('{') || bucket.contains('}') {
999            return Err(format!(
1000                "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1001                 only one"
1002            ));
1003        }
1004        return Ok(BundleUri::Literal(uri));
1005    };
1006
1007    if after.contains(BUNDLE_REGION_TOKEN) {
1008        return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1009    }
1010    if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1011        return Err(format!(
1012            "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1013        ));
1014    }
1015
1016    Ok(BundleUri::Regional {
1017        before: &uri[.."s3://".len() + before.len()],
1018        after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1019    })
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025
1026    fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1027        Sandbox::new("agent-sbx".to_string())
1028            .code(SandboxCode::Image {
1029                image: "ubuntu".to_string(),
1030            })
1031            .limits(SandboxLimits {
1032                cpu: "1".to_string(),
1033                memory: "2Gi".to_string(),
1034                disk: "20Gi".to_string(),
1035                max_processes: None,
1036            })
1037            .egress(egress)
1038            .session(SandboxSessionPolicy {
1039                max_lifetime_seconds: None,
1040                idle_suspend_seconds: None,
1041            })
1042            .preview_ports(preview_ports)
1043            .build()
1044    }
1045
1046    /// A URI with no token must come back whole, because every bundle configured today has none
1047    /// and emitting one differently would change every existing customer's template.
1048    #[test]
1049    fn a_uri_without_a_token_is_carried_whole() {
1050        assert_eq!(
1051            parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1052            Ok(BundleUri::Literal(
1053                "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1054            ))
1055        );
1056    }
1057
1058    /// The split has to rejoin to the original with the region in place, or an emitter builds a
1059    /// URI that is subtly not the one the vendor configured.
1060    #[test]
1061    fn a_regional_uri_splits_either_side_of_the_token() {
1062        let BundleUri::Regional { before, after } =
1063            parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1064                .expect("the token is accepted in the bucket")
1065        else {
1066            panic!("a bucket-position token must split");
1067        };
1068
1069        assert_eq!(before, "s3://acme-artifacts-");
1070        assert_eq!(after, "/agents/bundle.zip");
1071        assert_eq!(
1072            format!("{before}us-east-2{after}"),
1073            "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1074            "the halves must rejoin to the URI the vendor meant"
1075        );
1076    }
1077
1078    /// Each of these reaches S3 verbatim and dies ~160s into an image build if it is not refused
1079    /// here, which is the whole reason this runs at plan time.
1080    #[test]
1081    fn a_token_this_build_cannot_resolve_is_refused() {
1082        for uri in [
1083            "s3://acme-artifacts-{regio}/bundle.zip",
1084            "s3://acme-artifacts/{region}/bundle.zip",
1085            "s3://acme-artifacts-{region}-{region}/bundle.zip",
1086            "s3://acme-artifacts/bundle-{version}.zip",
1087            "s3://acme}-artifacts-{region}/bundle.zip",
1088            "s3://acme{-artifacts-{region}/bundle.zip",
1089        ] {
1090            assert!(
1091                parse_bundle_uri(uri).is_err(),
1092                "'{uri}' must be refused before it can reach an image build"
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn resource_type_is_stable() {
1099        assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1100    }
1101
1102    #[test]
1103    fn capability_sets_are_per_platform() {
1104        let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1105        assert!(
1106            gcp.reconnect,
1107            "generation from the container boot id makes a session reachable across processes"
1108        );
1109        assert!(!gcp.preview);
1110        assert!(gcp.enforced_limits);
1111
1112        let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1113        assert!(azure.files, "every backend moves files");
1114        assert!(gcp.files);
1115        // Azure is the only backend whose egress policy matches on host pattern, and the only
1116        // one where `deny` and a hostname list are the same object.
1117        assert!(azure.domain_egress_rules);
1118        assert!(azure.egress_deny);
1119        // The data plane takes no ceiling, so a declaration of one is refused rather than
1120        // accepted and dropped.
1121        assert!(!azure.enforced_limits);
1122        assert!(azure.suspend_resume);
1123        // Both stay false for reasons that are not "unbuilt": a snapshot id has nothing to
1124        // consume it on any backend, and an Azure port's auth is anonymous or a human allowlist,
1125        // neither of which is a port-scoped credential.
1126        assert!(!azure.snapshot);
1127        assert!(!azure.preview);
1128
1129        let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1130        assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
1131        assert!(aws.suspend_resume);
1132
1133        let k8s =
1134            SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1135        assert!(
1136            !k8s.preview,
1137            "the session-scoped ingress gateway does not exist yet"
1138        );
1139    }
1140
1141    /// Whether the process supervising a command is a separate identity from the command.
1142    ///
1143    /// Values are measured, not inferred. AWS: the agent runs as uid 0 with
1144    /// `CapEff: 00000000a80425fb` and `setuid`s the command to uid 60000, so the two differ.
1145    /// Kubernetes: the sandbox pod pins `run_as_user: 65534` on both pod and container with
1146    /// `capabilities.drop: [ALL]` and `allow_privilege_escalation: false`, so no uid split is
1147    /// possible (`kubernetes_spec.rs`). Local: `docker exec` runs as the workload uid while the
1148    /// manager supervises from the host. Azure and Agent Platform have no in-sandbox supervisor.
1149    #[test]
1150    fn supervisor_isolation_is_per_platform() {
1151        let value = |platform| {
1152            SandboxCapabilities::for_platform(platform)
1153                .expect("supported")
1154                .supervisor_isolation
1155        };
1156
1157        assert!(
1158            value(Platform::Aws),
1159            "root agent setuids the command to 60000"
1160        );
1161        assert!(
1162            value(Platform::Local),
1163            "the supervisor is on the host, outside the container"
1164        );
1165        assert!(
1166            !value(Platform::Kubernetes),
1167            "a single pinned uid cannot be split"
1168        );
1169        assert!(!value(Platform::Azure), "no Alien process runs the command");
1170        assert!(
1171            !value(Platform::Gcp),
1172            "no separate supervisor identity runs the command"
1173        );
1174    }
1175
1176    /// The point of the field: AWS and GCP report the *same* `supervisor_pid_namespace` (neither
1177    /// has `CAP_SYS_ADMIN`), so that axis alone reads them as equivalent. They are not — AWS
1178    /// separates the command's identity from the supervisor's and Agent Platform does not.
1179    #[test]
1180    fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1181        let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1182        let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1183
1184        assert_eq!(
1185            aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1186            "the older axis cannot tell them apart"
1187        );
1188        assert!(
1189            aws.supervisor_isolation,
1190            "AWS setuids the command off the supervisor"
1191        );
1192        assert!(
1193            !gcp.supervisor_isolation,
1194            "the command runs under no separate supervisor identity"
1195        );
1196    }
1197
1198    /// The Agent Platform row, each value against the behaviour it was measured from. `reconnect`
1199    /// is the tripwire: it is `true` only because `generation` is derived from the container boot
1200    /// id read through the agent's health op, so a caller detects a replaced container instead of
1201    /// reconnecting to a blank one. It is also the body of the `Platform::Gcp` arm, asserted below.
1202    #[test]
1203    fn gcp_agent_platform_row_matches_measured_backend() {
1204        let row = SandboxCapabilities::gcp_agent_platform();
1205
1206        assert!(row.files, "agent file ops move over the session envelope");
1207        assert!(
1208            row.reconnect,
1209            "generation is derived from the container boot id, so a session is reachable across \
1210             processes"
1211        );
1212        assert!(
1213            !row.preview,
1214            "the only ingress is :execute; no port-scoped capability"
1215        );
1216        assert!(
1217            row.suspend_resume,
1218            ":pause and :resume preserve the container"
1219        );
1220        assert!(
1221            row.snapshot,
1222            "session state can be captured and restored into a new session"
1223        );
1224        assert!(
1225            !row.domain_egress_rules,
1226            "VPC and DNS peering is not a hostname allowlist"
1227        );
1228        assert!(
1229            row.egress_deny,
1230            "a declared deny blocks both egress and DNS"
1231        );
1232        assert!(
1233            row.enforced_limits,
1234            "ceilings are enforced, by terminating the session on breach"
1235        );
1236        assert!(!row.process_limit, "no process-count ceiling is observed");
1237        assert!(row.session_lifetime, "ttl maps to a session expireTime");
1238        assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1239        assert!(
1240            !row.supervisor_isolation,
1241            "the command is not run under a separate supervisor identity"
1242        );
1243
1244        // Agent Platform is the registered GCP backend, so the arm returns exactly this row.
1245        let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1246        assert_eq!(
1247            live, row,
1248            "the Platform::Gcp arm is the Agent Platform capability row"
1249        );
1250    }
1251
1252    #[test]
1253    fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1254        let error = SandboxCapabilities::for_platform(Platform::Machines)
1255            .expect_err("Machines has no sandbox backend");
1256        assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1257    }
1258
1259    #[test]
1260    fn unsupported_capability_names_platform_and_capability() {
1261        let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1262        let error = capabilities
1263            .require(SandboxCapability::Preview, Platform::Gcp)
1264            .expect_err("GCP has no preview");
1265
1266        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1267        let rendered = error.to_string();
1268        assert!(
1269            rendered.contains("preview"),
1270            "names the capability: {rendered}"
1271        );
1272        assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1273    }
1274
1275    /// Azure matches on hostname; AWS and Kubernetes match CIDRs, and Local and GCP have a
1276    /// switch rather than a filter. Accepting a hostname list on those four would leave a stack
1277    /// reading as restricted while the sandbox reaches the whole internet.
1278    #[test]
1279    fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1280        let sandbox = sandbox_with(
1281            SandboxEgress::AllowDomains {
1282                domains: vec!["example.com".to_string()],
1283            },
1284            vec![],
1285        );
1286
1287        for platform in [
1288            Platform::Aws,
1289            Platform::Gcp,
1290            Platform::Kubernetes,
1291            Platform::Local,
1292        ] {
1293            let error = sandbox
1294                .validate_for_platform(platform)
1295                .expect_err("only Azure expresses a hostname allowlist");
1296            assert_eq!(
1297                error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1298                "on {platform:?}"
1299            );
1300        }
1301
1302        assert!(
1303            SandboxCapabilities::for_platform(Platform::Azure)
1304                .expect("supported")
1305                .domain_egress_rules,
1306            "Azure's egress policy matches on host pattern"
1307        );
1308    }
1309
1310    /// `deny` is the declaration that carries a security promise, so a backend that cannot keep
1311    /// it has to refuse rather than accept it and run the code with open egress.
1312    #[test]
1313    fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1314        let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1315
1316        // GCP is asserted at the capability rather than through validation: this sandbox declares
1317        // ceilings GCP cannot enforce, so it is refused for a reason unrelated to egress.
1318        assert!(
1319            SandboxCapabilities::for_platform(Platform::Gcp)
1320                .expect("supported")
1321                .egress_deny
1322        );
1323
1324        for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1325            sandbox
1326                .validate_for_platform(platform)
1327                .expect("deny is enforced here");
1328        }
1329
1330        // Declares no ceilings, which Azure refuses for its own reason, so this isolates egress.
1331        let egress_only = Sandbox::new("sbx".to_string())
1332            .code(SandboxCode::Image {
1333                image: "alpine".to_string(),
1334            })
1335            .egress(SandboxEgress::Deny)
1336            .session(SandboxSessionPolicy {
1337                max_lifetime_seconds: None,
1338                idle_suspend_seconds: None,
1339            })
1340            .build();
1341
1342        egress_only
1343            .validate_for_platform(Platform::Azure)
1344            .expect("Azure creates the sandbox under a Deny policy with full inspection");
1345    }
1346
1347    /// Ceilings are rejected per-platform where unsupported — rejected when *declared*. With
1348    /// limits mandatory that would read as "GCP sandboxes cannot exist", contradicting the
1349    /// create, exec, files and terminate GCP does support.
1350    #[test]
1351    fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() {
1352        let declared = sandbox_with(SandboxEgress::Deny, Vec::new());
1353        declared
1354            .validate_for_platform(Platform::Azure)
1355            .expect_err("declaring ceilings Azure cannot enforce is rejected");
1356
1357        let undeclared = Sandbox::new("sbx".to_string())
1358            .code(SandboxCode::Image {
1359                image: "alpine".to_string(),
1360            })
1361            .egress(SandboxEgress::Deny)
1362            .session(SandboxSessionPolicy {
1363                max_lifetime_seconds: None,
1364                idle_suspend_seconds: None,
1365            })
1366            .build();
1367
1368        undeclared
1369            .validate_for_platform(Platform::Azure)
1370            .expect("a sandbox naming no ceilings takes the platform's own");
1371
1372        // A backend still gets a concrete set, so nothing downstream has to invent one.
1373        assert_eq!(undeclared.resolved_limits().cpu, "1");
1374    }
1375
1376    #[test]
1377    fn preview_ports_require_the_preview_capability() {
1378        let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1379
1380        sandbox
1381            .validate_for_platform(Platform::Aws)
1382            .expect("AWS mints a port-scoped JWE");
1383
1384        let error = sandbox
1385            .validate_for_platform(Platform::Kubernetes)
1386            .expect_err("Kubernetes preview is deferred");
1387        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1388    }
1389
1390    #[test]
1391    fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1392        let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1393        sandbox
1394            .validate_for_platform(Platform::Gcp)
1395            .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1396    }
1397
1398    #[test]
1399    fn invalid_quantities_are_rejected_with_the_offending_field() {
1400        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1401        sandbox
1402            .limits
1403            .as_mut()
1404            .expect("the fixture declares limits")
1405            .memory = "2Gb".to_string();
1406
1407        let error = sandbox
1408            .validate_for_platform(Platform::Aws)
1409            .expect_err("Gb is not a valid suffix");
1410        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1411        assert!(error.to_string().contains("memory"));
1412
1413        sandbox
1414            .limits
1415            .as_mut()
1416            .expect("the fixture declares limits")
1417            .memory = "2Gi".to_string();
1418        sandbox
1419            .limits
1420            .as_mut()
1421            .expect("the fixture declares limits")
1422            .cpu = "0".to_string();
1423        let error = sandbox
1424            .validate_for_platform(Platform::Aws)
1425            .expect_err("zero cpu is not a ceiling");
1426        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1427    }
1428
1429    #[test]
1430    fn zero_max_processes_is_rejected() {
1431        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1432        sandbox
1433            .limits
1434            .as_mut()
1435            .expect("the fixture declares limits")
1436            .max_processes = Some(0);
1437
1438        let error = sandbox
1439            .validate_for_platform(Platform::Local)
1440            .expect_err("a sandbox must be able to run at least one process");
1441        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1442        assert!(error.to_string().contains("maxProcesses"));
1443    }
1444
1445    /// A process ceiling needs a container runtime. Kubernetes sets one per node rather than per
1446    /// pod, and neither MicroVMs nor Azure sandboxes expose one, so accepting the declaration
1447    /// anywhere else would mean carrying a bound nothing applies.
1448    #[test]
1449    fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1450        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1451        sandbox
1452            .limits
1453            .as_mut()
1454            .expect("the fixture declares limits")
1455            .max_processes = Some(256);
1456
1457        sandbox
1458            .validate_for_platform(Platform::Local)
1459            .expect("Docker takes a pids limit");
1460
1461        for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1462            let error = sandbox
1463                .validate_for_platform(platform)
1464                .expect_err("a process ceiling nothing applies must be refused");
1465            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1466        }
1467    }
1468
1469    /// Lambda rejects a run outside 1–28,800 rather than clamping it, so a value beyond that
1470    /// would pass planning, render into the package, and fail at the first session. Kubernetes
1471    /// takes the same field with no such bound, so the check is AWS's alone.
1472    #[test]
1473    fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1474        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1475
1476        for seconds in [0, 28_801, 100_000] {
1477            sandbox.session.max_lifetime_seconds = Some(seconds);
1478            let error = sandbox
1479                .validate_for_platform(Platform::Aws)
1480                .expect_err("a lifetime outside what AWS runs is refused");
1481            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1482
1483            // Kubernetes has no such ceiling, so the same declaration is fine there.
1484            sandbox
1485                .validate_for_platform(Platform::Kubernetes)
1486                .expect("the kubelet takes any activeDeadlineSeconds");
1487        }
1488
1489        sandbox.session.max_lifetime_seconds = Some(28_800);
1490        sandbox
1491            .validate_for_platform(Platform::Aws)
1492            .expect("the ceiling itself is allowed");
1493    }
1494
1495    /// An image reference Azure cannot honour is refused while planning, not at the first session.
1496    ///
1497    /// `code.image`'s own documentation gives a tag and a registry path as examples — exactly
1498    /// what Azure cannot take, so this is the shape a customer is most likely to declare.
1499    #[test]
1500    fn an_image_azure_cannot_pull_is_refused_while_planning() {
1501        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1502        // Azure enforces no declared ceiling, so a sandbox carrying limits is refused before the
1503        // image is ever read.
1504        sandbox.limits = None;
1505
1506        for image in [
1507            "ubuntu:24.04",
1508            "ghcr.io/myorg/sandbox:latest",
1509            "ubuntu@sha256:abc",
1510            "",
1511            "   ",
1512            "ubuntu latest",
1513            "ubuntu?x",
1514        ] {
1515            sandbox.code = SandboxCode::Image {
1516                image: image.to_string(),
1517            };
1518            let error = sandbox
1519                .validate_for_platform(Platform::Azure)
1520                .expect_err("an image Azure has nowhere to put is refused");
1521            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1522
1523            // The same declaration is ordinary everywhere that pulls a reference.
1524            sandbox
1525                .validate_for_platform(Platform::Kubernetes)
1526                .expect("a registry reference is what every other backend takes");
1527        }
1528
1529        for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1530            sandbox.code = SandboxCode::Image {
1531                image: image.to_string(),
1532            };
1533            sandbox
1534                .validate_for_platform(Platform::Azure)
1535                .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1536        }
1537
1538        // Surrounding space is trimmed rather than carried into the create body.
1539        sandbox.code = SandboxCode::Image {
1540            image: " ubuntu ".to_string(),
1541        };
1542        assert_eq!(
1543            sandbox
1544                .azure_catalog_image()
1545                .expect("a padded name is still a name"),
1546            "ubuntu"
1547        );
1548    }
1549
1550    /// A deadline is accepted only where the platform itself terminates on it — the kubelet's
1551    /// `activeDeadlineSeconds` and Lambda's `maximumDurationInSeconds`. Everywhere else it would
1552    /// need a reaper that does not exist, so it is refused rather than accepted and dropped.
1553    #[test]
1554    fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1555        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1556        sandbox.session.max_lifetime_seconds = Some(3600);
1557
1558        sandbox
1559            .validate_for_platform(Platform::Kubernetes)
1560            .expect("the kubelet enforces activeDeadlineSeconds");
1561        sandbox
1562            .validate_for_platform(Platform::Aws)
1563            .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1564
1565        for platform in [Platform::Azure, Platform::Local] {
1566            let error = sandbox
1567                .validate_for_platform(platform)
1568                .expect_err("a deadline nothing applies must be refused");
1569            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1570        }
1571    }
1572
1573    /// A MicroVM bursts to four times its baseline with no way to opt out, so a ceiling is kept
1574    /// by choosing the size whose *peak* fits inside it. Sizing by baseline would hand back a
1575    /// sandbox that can reach four times what the customer declared.
1576    #[test]
1577    fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1578        let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1579        let tier = sandbox
1580            .microvm_tier()
1581            .expect("2Gi/1cpu/20Gi is satisfiable");
1582
1583        assert_eq!(
1584            tier.peak_memory_mib, 2048,
1585            "the peak is the declared ceiling"
1586        );
1587        assert_eq!(
1588            tier.baseline_memory_mib, 512,
1589            "which is a quarter of it as the baseline"
1590        );
1591        assert!(tier.max_disk_mib <= 20 * 1024);
1592    }
1593
1594    /// AWS allocates one vCPU per 2GB, so a cpu ceiling below what the memory ceiling implies
1595    /// cannot be honoured together with it. Letting cpu choose the size instead would hand back a
1596    /// machine four times smaller than the memory asked for, with nothing to indicate it.
1597    #[test]
1598    fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1599        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1600        {
1601            let limits = sandbox
1602                .limits
1603                .as_mut()
1604                .expect("the fixture declares limits");
1605            limits.cpu = "1".to_string();
1606            limits.memory = "8Gi".to_string();
1607        }
1608
1609        let error = sandbox
1610            .microvm_tier()
1611            .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1612        assert!(
1613            error.to_string().contains("4 vCPU"),
1614            "the refusal must say what the memory ceiling implies: {error}"
1615        );
1616
1617        sandbox
1618            .limits
1619            .as_mut()
1620            .expect("the fixture declares limits")
1621            .cpu = "4".to_string();
1622        let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1623        assert_eq!(tier.peak_memory_mib, 8192);
1624    }
1625
1626    /// Below AWS's smallest peak there is no size that holds the ceiling, and rounding up to the
1627    /// nearest one would silently exceed it.
1628    #[test]
1629    fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1630        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1631        sandbox
1632            .limits
1633            .as_mut()
1634            .expect("the fixture declares limits")
1635            .memory = "1Gi".to_string();
1636
1637        let error = sandbox
1638            .validate_for_platform(Platform::Aws)
1639            .expect_err("no MicroVM size peaks at or below 1Gi");
1640        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1641        assert!(
1642            error.to_string().contains("2Gi"),
1643            "the refusal must say what the smallest holdable ceiling is: {error}"
1644        );
1645    }
1646
1647    /// `Source` is a public part of the type that no backend builds: an empty image string
1648    /// schedules a pod that can never run, so the refusal has to happen at plan time and on
1649    /// every platform, not in one emitter.
1650    #[test]
1651    fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1652        let sandbox = Sandbox::new("agent".to_string())
1653            .code(SandboxCode::Source {
1654                src: "./sandbox".to_string(),
1655                toolchain: ToolchainConfig::Docker {
1656                    dockerfile: None,
1657                    build_args: None,
1658                    target: None,
1659                },
1660            })
1661            .egress(SandboxEgress::Deny)
1662            .session(SandboxSessionPolicy {
1663                max_lifetime_seconds: None,
1664                idle_suspend_seconds: None,
1665            })
1666            .build();
1667
1668        for platform in [
1669            Platform::Aws,
1670            Platform::Azure,
1671            Platform::Gcp,
1672            Platform::Kubernetes,
1673            Platform::Local,
1674        ] {
1675            let error = sandbox
1676                .validate_for_platform(platform)
1677                .expect_err("no backend builds a sandbox image from source");
1678            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1679            assert!(
1680                error.to_string().contains("code.image"),
1681                "the refusal must say what to write instead: {error}"
1682            );
1683        }
1684    }
1685
1686    /// `validate_quantity` accepts nine suffixes. Reading only `Gi` and `Mi` would size a
1687    /// declared `4G` as though it were `4Gi`, which for a ceiling means exceeding it.
1688    #[test]
1689    fn every_accepted_unit_converts_rather_than_falling_back() {
1690        assert_eq!(quantity_mib("2Gi"), Some(2048));
1691        assert_eq!(quantity_mib("512Mi"), Some(512));
1692        assert_eq!(quantity_mib("4G"), Some(3814));
1693        assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1694        assert_eq!(millicores("1"), Some(1000));
1695        assert_eq!(millicores("500m"), Some(500));
1696    }
1697
1698    #[test]
1699    fn unknown_fields_are_rejected() {
1700        let json = r#"{
1701            "id": "sbx",
1702            "code": {"type": "image", "image": "ubuntu:24.04"},
1703            "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1704            "egress": {"mode": "deny"},
1705            "session": {},
1706            "unexpected": true
1707        }"#;
1708
1709        serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1710    }
1711
1712    #[test]
1713    fn serialization_roundtrips() {
1714        let sandbox = sandbox_with(
1715            SandboxEgress::AllowDomains {
1716                domains: vec!["example.com".to_string()],
1717            },
1718            vec![8080, 9090],
1719        );
1720
1721        let json = serde_json::to_string(&sandbox).expect("serializes");
1722        let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1723        assert_eq!(sandbox, restored);
1724    }
1725
1726    #[test]
1727    fn id_is_immutable_across_updates() {
1728        let original = sandbox_with(SandboxEgress::Deny, vec![]);
1729        let renamed = Sandbox::new("other".to_string())
1730            .code(SandboxCode::Image {
1731                image: "ubuntu".to_string(),
1732            })
1733            .limits(
1734                original
1735                    .limits
1736                    .clone()
1737                    .expect("the fixture declares limits"),
1738            )
1739            .egress(SandboxEgress::Deny)
1740            .session(SandboxSessionPolicy {
1741                max_lifetime_seconds: None,
1742                idle_suspend_seconds: None,
1743            })
1744            .build();
1745
1746        original
1747            .validate_update(&original.clone())
1748            .expect("an unchanged config is a valid update");
1749        original
1750            .validate_update(&renamed)
1751            .expect_err("renaming a sandbox is not an update");
1752    }
1753
1754    /// Azure declares an idle-suspend policy but not a wall-clock ceiling.
1755    ///
1756    /// The two travel together in `SandboxSessionPolicy` and are gated separately on purpose:
1757    /// Azure suspends on idle and has no maximum lifetime, so accepting one and refusing the
1758    /// other is the honest split rather than an inconsistency.
1759    #[test]
1760    fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
1761        let with_policy = |session: SandboxSessionPolicy| {
1762            Sandbox::new("sbx".to_string())
1763                .code(SandboxCode::Image {
1764                    image: "ubuntu".to_string(),
1765                })
1766                .egress(SandboxEgress::Allow)
1767                .session(session)
1768                .build()
1769                .validate_for_platform(Platform::Azure)
1770        };
1771
1772        with_policy(SandboxSessionPolicy {
1773            max_lifetime_seconds: None,
1774            idle_suspend_seconds: Some(900),
1775        })
1776        .expect("Azure suspends a session on idle");
1777
1778        let error = with_policy(SandboxSessionPolicy {
1779            max_lifetime_seconds: Some(3600),
1780            idle_suspend_seconds: None,
1781        })
1782        .expect_err("Azure has no wall-clock ceiling to enforce one with");
1783        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1784        assert!(
1785            error.message.contains("sessionLifetime"),
1786            "names the capability: {}",
1787            error.message
1788        );
1789    }
1790
1791    /// An allowlist naming nothing is a deny-all wearing an allowlist's label.
1792    ///
1793    /// It renders as a `Deny` default with no rules — the shape the Azure provider adds a
1794    /// catch-all to avoid — and a reader scanning the declaration sees "allowDomains" and reads
1795    /// the opposite of what it does.
1796    #[test]
1797    fn an_allowlist_with_no_domains_is_refused() {
1798        let declared = |domains: Vec<String>| {
1799            Sandbox::new("sbx".to_string())
1800                .code(SandboxCode::Image {
1801                    image: "ubuntu".to_string(),
1802                })
1803                .egress(SandboxEgress::AllowDomains { domains })
1804                .session(SandboxSessionPolicy {
1805                    max_lifetime_seconds: None,
1806                    idle_suspend_seconds: None,
1807                })
1808                .build()
1809                .validate_for_platform(Platform::Azure)
1810        };
1811
1812        let error = declared(vec![]).expect_err("an empty allowlist must be refused");
1813        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1814
1815        declared(vec!["api.example.com".to_string()])
1816            .expect("a named domain is what an allowlist is for");
1817    }
1818
1819    /// The two expressible modes map to the boolean; a host list maps to nothing so the caller has
1820    /// to refuse rather than silently pick a side.
1821    #[test]
1822    fn internet_access_switch_maps_only_the_two_expressible_modes() {
1823        assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
1824        assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
1825        assert_eq!(
1826            SandboxEgress::AllowDomains {
1827                domains: vec!["api.example.com".to_string()]
1828            }
1829            .internet_access_switch(),
1830            None,
1831            "a host list has no boolean and must not be approximated"
1832        );
1833    }
1834}