Skip to main content

alien_core/
sandbox_image.rs

1//! What a sandbox image must carry for the agent to serve, and the Dockerfile text carrying it.
2//!
3//! Two kinds of image exist and neither is built the way the other is. AWS renders one per
4//! deployment onto a customer-supplied base image; GCP builds static images in CI. Nothing at build
5//! time reads the other side, and a value that disagrees is invisible until a session fails: an
6//! agent listening on a port no caller dials, or an exec into a uid the image never created.
7//!
8//! So the values live here once and both kinds render the block that carries them from this
9//! module. The same holds for the default sandbox base both kinds start from: its toolchain is
10//! listed here once, and a probe reads that list rather than restating it. The Dockerfiles and the
11//! tool list are committed as generated text because CI builds them with `docker build` and probes
12//! them from a shell, neither of which can call a Rust function.
13
14/// Path the agent binary is installed at inside every sandbox image.
15///
16/// An image that installs one path and entrypoints another exits before it serves, and the
17/// session times out on connect.
18pub const AGENT_PATH: &str = "/usr/local/bin/alien-sandbox-agent";
19
20/// Port the agent serves unless the platform pins another.
21///
22/// Defined once because two independent copies are a runtime-only failure: the image build places
23/// the agent on one port and the client dials the other, and nothing catches it until a sandbox
24/// hangs. AWS scopes its endpoint token to an explicit port set, so this cannot be discovered.
25pub const AGENT_PORT: u16 = 8971;
26
27/// Mode of the agent binary, owned by `0:0` so the exec uid cannot rewrite its supervisor.
28pub const AGENT_MODE: u32 = 0o755;
29
30/// Name of the exec identity's passwd and group entries.
31pub const EXEC_USER: &str = "sandbox";
32
33/// Mode of the session root, which the exec uid owns.
34pub const SESSION_ROOT_MODE: u32 = 0o700;
35
36/// `RUST_LOG` for a GCP image, which the agent needs set before it logs anything.
37pub const GCP_AGENT_LOG_FILTER: &str = "info";
38
39/// How an image ends, and the isolation that ending permits.
40///
41/// One value rather than two, because `ALIEN_SANDBOX_ISOLATION` and the trailing `USER` describe
42/// the same decision from opposite sides: an image that asks for `uid-split` under a non-root
43/// `USER` has no privilege left to drop with, and every exec in it fails.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Isolation {
46    /// The agent starts as root and drops to the exec uid before every spawn, so a command can
47    /// never rewrite its own supervisor. The image declares no `USER`.
48    UidSplit,
49    /// The agent starts as the exec uid and supervises commands under it, which is all a runtime
50    /// that refuses a root image can offer. The image ends `USER <uid>:<uid>`.
51    Platform,
52}
53
54impl Isolation {
55    /// The `ALIEN_SANDBOX_ISOLATION` value the agent parses.
56    pub fn env_value(self) -> &'static str {
57        match self {
58            Self::UidSplit => "uid-split",
59            Self::Platform => "platform",
60        }
61    }
62}
63
64/// How the agent decides a caller may be served.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Authorization {
67    /// The connection is the proof. The agent serves an uncapabilitied request only from a socket
68    /// peer it cannot trace back to the exec uid, which keeps the supervised command out.
69    Transport,
70    /// The caller presents a signed token, which is what a network anything can reach requires.
71    Capability,
72}
73
74impl Authorization {
75    /// The `ALIEN_SANDBOX_AUTHORIZATION` value the agent parses.
76    pub fn env_value(self) -> &'static str {
77        match self {
78            Self::Transport => "transport",
79            Self::Capability => "capability",
80        }
81    }
82}
83
84/// The values an image must carry for the agent to run in it.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct SandboxImage {
87    /// Uid and gid the supervised command runs as.
88    pub exec_uid: u32,
89    /// Directory a session's files live under, and the only place `exec_uid` may write.
90    pub session_root: &'static str,
91    /// Port the agent serves, both its own protocol and any lifecycle hooks.
92    pub port: u16,
93    pub authorization: Authorization,
94    pub isolation: Isolation,
95}
96
97impl SandboxImage {
98    /// Gid the supervised command runs as, always equal to the exec uid.
99    pub fn exec_gid(&self) -> u32 {
100        self.exec_uid
101    }
102
103    /// The agent's configuration contract as `(name, value)` pairs, in the order the `ENV` block
104    /// lists them.
105    pub fn contract_env_vars(&self) -> [(&'static str, String); 6] {
106        [
107            ("ALIEN_SANDBOX_ROOT", self.session_root.to_string()),
108            ("ALIEN_SANDBOX_PORT", self.port.to_string()),
109            (
110                "ALIEN_SANDBOX_AUTHORIZATION",
111                self.authorization.env_value().to_string(),
112            ),
113            ("ALIEN_SANDBOX_EXEC_UID", self.exec_uid.to_string()),
114            ("ALIEN_SANDBOX_EXEC_GID", self.exec_gid().to_string()),
115            (
116                "ALIEN_SANDBOX_ISOLATION",
117                self.isolation.env_value().to_string(),
118            ),
119        ]
120    }
121
122    /// The `uid:gid` the image runs as, or `None` when it declares no user and starts as root.
123    /// Why the gid is explicit is in the `USER` comment [`entrypoint`] renders.
124    pub fn user(&self) -> Option<String> {
125        match self.isolation {
126            Isolation::UidSplit => None,
127            Isolation::Platform => Some(format!("{}:{}", self.exec_uid, self.exec_gid())),
128        }
129    }
130
131    /// The port the image exposes, as an OCI `ExposedPorts` key.
132    pub fn exposed_port(&self) -> String {
133        format!("{}/tcp", self.port)
134    }
135
136    /// The exec identity's `/etc/passwd` line, without a trailing newline.
137    pub fn passwd_entry(&self) -> String {
138        format!(
139            "{EXEC_USER}:x:{uid}:{gid}::{root}:/sbin/nologin",
140            uid = self.exec_uid,
141            gid = self.exec_gid(),
142            root = self.session_root,
143        )
144    }
145
146    /// The exec identity's `/etc/group` line, without a trailing newline.
147    pub fn group_entry(&self) -> String {
148        format!("{EXEC_USER}:x:{gid}:", gid = self.exec_gid())
149    }
150}
151
152/// The Lambda MicroVM image, rendered per deployment onto a customer base image.
153///
154/// The agent runs as root so it can drop to [`SandboxImage::exec_uid`] before every spawn; inside
155/// a MicroVM that is contained by hardware virtualisation, which is the tenant boundary. A
156/// shared-kernel backend must give the agent `CAP_SETUID` instead of root.
157pub const AWS_MICROVM: SandboxImage = SandboxImage {
158    exec_uid: 60000,
159    session_root: "/sandbox",
160    port: AGENT_PORT,
161    authorization: Authorization::Transport,
162    isolation: Isolation::UidSplit,
163};
164
165/// The GCP Agent Platform image, built once in CI and run with nothing layered on top.
166///
167/// 1000 is the conventional first non-root uid, chosen over the 60000 [`AWS_MICROVM`] uses because
168/// that value was never tried against Agent Platform. One uid covers the agent and the commands it
169/// supervises: Agent Platform refuses an image that requires root, so there is no second uid to
170/// drop to.
171///
172/// Agent Platform is only known to serve 8080, on the image and on the declared template port
173/// alike, and whether it honours a declared port or assumes 8080 has never been established.
174/// 8080 is right under either answer; any other number is right under only one.
175pub const GCP_AGENT_PLATFORM: SandboxImage = SandboxImage {
176    exec_uid: 1000,
177    session_root: "/sandbox",
178    port: 8080,
179    authorization: Authorization::Transport,
180    isolation: Isolation::Platform,
181};
182
183/// Every variable a GCP Agent Platform image sets: the contract, then `RUST_LOG`.
184pub fn gcp_agent_platform_env() -> Vec<(&'static str, String)> {
185    let mut env = GCP_AGENT_PLATFORM.contract_env_vars().to_vec();
186    env.push(("RUST_LOG", GCP_AGENT_LOG_FILTER.to_string()));
187    env
188}
189
190/// The image every default sandbox builds on, by index digest so both architectures are pinned.
191///
192/// Bumped by hand, then regenerated: Renovate skips the generated Dockerfiles and does not read
193/// this file.
194pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "public.ecr.aws/docker/library/buildpack-deps:26.04@sha256:159ea382e6fb39e62480ee932113f885f7bd787cd4895fc4dc71aebb175077fd";
195
196/// The image uv and uvx are copied out of, by index digest.
197pub const DEFAULT_SANDBOX_UV_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.21@sha256:a7aed3216253ee804de3e2d8afa5073baa1a177335345d43845cd4165e43b711";
198
199/// A tool the default sandbox base ships, and the command that shows it runs.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct SandboxTool {
202    /// The Ubuntu archive package it comes from, or `None` for one copied out of
203    /// [`DEFAULT_SANDBOX_UV_IMAGE`].
204    pub package: Option<&'static str>,
205    /// Prints the tool's version and exits zero.
206    pub version_command: &'static str,
207}
208
209/// The toolchain of the default sandbox base, and the one list its probes read.
210///
211/// Every tool lands in `/usr/bin` or `/usr/local/bin`: a supervised command gets a fixed `PATH`
212/// and never the image's `ENV`, so a tool anywhere else is invisible to it.
213pub const DEFAULT_SANDBOX_TOOLS: &[SandboxTool] = &[
214    SandboxTool {
215        package: Some("nftables"),
216        version_command: "/usr/sbin/nft --version",
217    },
218    SandboxTool {
219        package: Some("iptables"),
220        version_command: "iptables-nft --version",
221    },
222    SandboxTool {
223        package: Some("nodejs"),
224        version_command: "node --version",
225    },
226    SandboxTool {
227        package: Some("npm"),
228        version_command: "npm --version",
229    },
230    SandboxTool {
231        package: Some("ripgrep"),
232        version_command: "rg --version",
233    },
234    SandboxTool {
235        package: Some("jq"),
236        version_command: "jq --version",
237    },
238    SandboxTool {
239        package: Some("zip"),
240        version_command: "zip -v",
241    },
242    SandboxTool {
243        package: Some("less"),
244        version_command: "less --version",
245    },
246    SandboxTool {
247        package: Some("nano"),
248        version_command: "nano --version",
249    },
250    SandboxTool {
251        package: Some("vim-tiny"),
252        version_command: "vim.tiny --version",
253    },
254    SandboxTool {
255        package: Some("htop"),
256        version_command: "htop --version",
257    },
258    SandboxTool {
259        package: None,
260        version_command: "uv --version",
261    },
262    SandboxTool {
263        package: None,
264        version_command: "uvx --version",
265    },
266];
267
268/// The `RUN` step creating the exec identity and the session root it owns.
269pub fn identity_setup(image: &SandboxImage) -> String {
270    format!(
271        r#"RUN printf '{passwd}\n' >> /etc/passwd \
272 && printf '{group}\n' >> /etc/group \
273 && mkdir -p {root} \
274 && chown {uid}:{gid} {root} \
275 && chmod {SESSION_ROOT_MODE:04o} {root}"#,
276        passwd = image.passwd_entry(),
277        group = image.group_entry(),
278        root = image.session_root,
279        uid = image.exec_uid,
280        gid = image.exec_gid(),
281    )
282}
283
284/// The `ENV` block carrying the agent's configuration contract.
285pub fn contract_env(image: &SandboxImage) -> String {
286    let vars: Vec<String> = image
287        .contract_env_vars()
288        .iter()
289        .map(|(name, value)| format!("{name}={value}"))
290        .collect();
291    format!("ENV {}", vars.join(" \\\n    "))
292}
293
294/// `EXPOSE`, the image's ending, and the `ENTRYPOINT`.
295pub fn entrypoint(image: &SandboxImage) -> String {
296    let ending = match image.user() {
297        None => String::new(),
298        Some(user) => format!(
299            "# Explicit gid so a runtime that does not read /etc/passwd cannot start the agent in \
300             group 0, which\n# makes the exec drop a privilege crossing whose setgroups needs a \
301             CAP_SETGID this image lacks, so\n# every exec fails.\nUSER {user}\n"
302        ),
303    };
304    format!(
305        "EXPOSE {port}\n{ending}ENTRYPOINT [\"{AGENT_PATH}\"]",
306        port = image.exposed_port()
307    )
308}
309
310/// Path of the committed minimal wolfi GCP Dockerfile, relative to the repository root.
311#[cfg(test)]
312const GCP_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-agent";
313
314/// Path of the committed default GCP sandbox Dockerfile, relative to the repository root.
315#[cfg(test)]
316const GCP_DEFAULT_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-gcp";
317
318/// Path of the committed default sandbox base Dockerfile, relative to the repository root.
319#[cfg(test)]
320const DEFAULT_SANDBOX_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-default";
321
322/// Path of the committed tool list a probe of the default sandbox base reads.
323#[cfg(test)]
324const DEFAULT_SANDBOX_TOOLS_FILE: &str = "docker/sandbox-default-tools.txt";
325
326/// Set to regenerate every committed file this module renders instead of comparing against them.
327/// Named for the agent Dockerfile; every generated header prints the name, so a rename rewrites all.
328#[cfg(test)]
329const SANDBOX_FILES_UPDATE: &str = "UPDATE_SANDBOX_AGENT_DOCKERFILE";
330
331/// Renders [`DEFAULT_SANDBOX_DOCKERFILE`], the tools-only base every default sandbox builds on.
332#[cfg(test)]
333fn default_sandbox_dockerfile() -> String {
334    let packages: Vec<&str> = DEFAULT_SANDBOX_TOOLS
335        .iter()
336        .filter_map(|tool| tool.package)
337        .collect();
338    format!(
339        r#"# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
340# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
341#
342# Multi-arch tools-only base for the default sandbox images, with no agent. An image that runs the
343# agent adds it and its own ending on top, and a runtime with no in-guest agent runs this as is,
344# so it declares no USER, ENTRYPOINT or ENV.
345
346FROM {DEFAULT_SANDBOX_BASE_IMAGE}
347
348# No version pins: the -updates and -security pockets supersede a pinned version and the arm64
349# ports lag behind, so a pin breaks the build. The published digest is what fixes the versions.
350RUN apt-get update \
351 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
352      {packages} \
353 && rm -rf /var/lib/apt/lists/*
354
355COPY --from={DEFAULT_SANDBOX_UV_IMAGE} /uv /uvx /usr/local/bin/
356"#,
357        packages = packages.join(" "),
358    )
359}
360
361/// Renders [`DEFAULT_SANDBOX_TOOLS_FILE`], one version command per line.
362#[cfg(test)]
363fn default_sandbox_tools_list() -> String {
364    let mut list = format!(
365        "# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
366# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
367#
368# One command per tool the default sandbox base ships; each prints a version and exits zero.
369"
370    );
371    for tool in DEFAULT_SANDBOX_TOOLS {
372        list.push_str(tool.version_command);
373        list.push('\n');
374    }
375    list
376}
377
378/// Where a GCP image's final stage starts.
379#[cfg(test)]
380enum GcpBase {
381    Image(&'static str),
382    /// A build-arg with no default, which whoever builds the image must pass.
383    BuildArg(&'static str),
384}
385
386/// Renders [`GCP_DOCKERFILE`], the minimal wolfi image.
387#[cfg(test)]
388fn gcp_agent_platform_dockerfile() -> String {
389    gcp_dockerfile(
390        "Multi-arch build for the alien-sandbox-agent Docker image",
391        GcpBase::Image("docker.io/chainguard/wolfi-base:latest"),
392        "# git is for the sandboxed command, not the agent, and pulls 24 transitive packages. That cost
393# lands here because this image is the sandbox, with no customer base image underneath to carry it.
394RUN apk add --no-cache git",
395    )
396}
397
398/// Renders [`GCP_DEFAULT_DOCKERFILE`], the default GCP sandbox image: the agent on the default
399/// sandbox base ([`DEFAULT_SANDBOX_DOCKERFILE`]), which whoever builds it passes by digest.
400#[cfg(test)]
401fn gcp_default_sandbox_dockerfile() -> String {
402    assert_eq!(
403        GCP_AGENT_PLATFORM.exec_uid, 1000,
404        "the userdel below exists only because Ubuntu's own user holds the exec uid"
405    );
406    gcp_dockerfile(
407        "Multi-arch build for the default GCP sandbox image: the default sandbox base plus the agent",
408        GcpBase::BuildArg("SANDBOX_DEFAULT_BASE"),
409        "# Ubuntu ships `ubuntu` at uid 1000. Appending a second entry for that uid leaves `id` and every
410# tool resolving it to `ubuntu`, so the exec user would not be `sandbox`.
411RUN userdel --remove ubuntu",
412    )
413}
414
415/// Renders a GCP Dockerfile onto `base`, with `base_setup` run as root before the agent lands.
416///
417/// Everything outside the shared block is here because it is true of the GCP images alone: the
418/// `binary-selector` stage, which exists because the release workflow builds one manifest for two
419/// architectures from binaries cross-compiled outside Docker; and `RUST_LOG`, which the agent's own
420/// `EnvFilter` needs before it will emit anything.
421#[cfg(test)]
422fn gcp_dockerfile(title: &str, base: GcpBase, base_setup: &str) -> String {
423    let image = &GCP_AGENT_PLATFORM;
424    // An ARG read by a FROM must precede the first FROM, or it is scoped to a stage and empty.
425    // BuildKit reads a parser directive only on the first lines, before any other comment.
426    let (directive, base_arg, base) = match base {
427        GcpBase::Image(reference) => (String::new(), String::new(), reference.to_string()),
428        GcpBase::BuildArg(name) => (
429            "# check=skip=InvalidDefaultArgInFrom\n".to_string(),
430            format!(
431                "# No default: the base's digest exists only once it is built, so whoever builds \
432                 this image\n# passes it. The check directive on line 1 is for this.\nARG {name}\n\n"
433            ),
434            format!("${{{name}}}"),
435        ),
436    };
437    format!(
438        r#"{directive}# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
439# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
440#
441# {title}
442# Run directly as the GCP Agent Platform sandbox; nothing layers on top of it
443
444{base_arg}FROM docker.io/chainguard/wolfi-base:latest AS binary-selector
445
446COPY target/aarch64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-aarch64
447COPY target/x86_64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-x86_64
448
449ARG TARGETARCH
450RUN case "$TARGETARCH" in \
451       amd64)  cp /tmp/alien-sandbox-agent-x86_64 /tmp/alien-sandbox-agent ;; \
452       arm64)  cp /tmp/alien-sandbox-agent-aarch64 /tmp/alien-sandbox-agent ;; \
453       *)      echo "unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
454    esac
455
456FROM {base}
457
458{base_setup}
459
460# Root-owned and unwritable by uid {exec_uid}: the supervised command runs under that uid and must not
461# be able to rewrite its own supervisor.
462COPY --from=binary-selector --chown=0:0 --chmod={AGENT_MODE:04o} \
463     /tmp/alien-sandbox-agent {AGENT_PATH}
464
465# Numeric ids and a plain append rather than adduser, which differs across base distributions.
466# Linux runs a process under a uid with no passwd entry, but tooling inside the sandbox reads one.
467{identity}
468
469# The template carries no env, so the contract lives here, and none of it is optional. transport
470# serves an uncapabilitied request only from a socket `peer::transport_may_serve` cannot trace
471# back to the exec uid, and the agent refuses the mode where /proc/net/tcp is unreadable.
472{env}
473
474# The release build resolves tracing-subscriber once across every package it names, and five of
475# them ask for env-filter, so the agent's fmt::init() has an EnvFilter under it. Unset, that
476# filter discards the startup warning saying this image serves requests without a capability.
477ENV RUST_LOG={GCP_AGENT_LOG_FILTER}
478
479{entrypoint}
480"#,
481        exec_uid = image.exec_uid,
482        identity = identity_setup(image),
483        env = contract_env(image),
484        entrypoint = entrypoint(image),
485    )
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn committed_path(relative: &str) -> std::path::PathBuf {
493        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
494            .join("../..")
495            .join(relative)
496    }
497
498    /// Where two texts first part, in terms a reader can act on. `assert_eq!` on a whole
499    /// Dockerfile prints two escaped blobs and says nothing about which line moved.
500    fn first_difference(committed: &str, rendered: &str) -> String {
501        for (index, (left, right)) in committed.lines().zip(rendered.lines()).enumerate() {
502            if left != right {
503                let line = index + 1;
504                return format!("line {line}: committed {left:?}, contract renders {right:?}");
505            }
506        }
507        format!(
508            "committed has {} lines, the contract renders {}",
509            committed.lines().count(),
510            rendered.lines().count()
511        )
512    }
513
514    /// The whole file, not chosen properties. A mutation this comparison cannot see is one that
515    /// leaves the bytes alone, and there is no such mutation.
516    #[test]
517    fn the_committed_gcp_dockerfiles_are_what_the_contract_renders() {
518        for (file, rendered) in [
519            (GCP_DOCKERFILE, gcp_agent_platform_dockerfile()),
520            (GCP_DEFAULT_DOCKERFILE, gcp_default_sandbox_dockerfile()),
521            (DEFAULT_SANDBOX_DOCKERFILE, default_sandbox_dockerfile()),
522            (DEFAULT_SANDBOX_TOOLS_FILE, default_sandbox_tools_list()),
523        ] {
524            let path = committed_path(file);
525
526            if std::env::var_os(SANDBOX_FILES_UPDATE).is_some() {
527                std::fs::write(&path, &rendered)
528                    .unwrap_or_else(|error| panic!("{} must be writable: {error}", path.display()));
529                continue;
530            }
531
532            let committed = std::fs::read_to_string(&path)
533                .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display()));
534            assert!(
535                committed == rendered,
536                "{file} has drifted from the contract it is rendered from.\n\
537                 {}\n\
538                 Regenerate it: {SANDBOX_FILES_UPDATE}=1 cargo test -p alien-core --lib sandbox_image",
539                first_difference(&committed, &rendered)
540            );
541        }
542    }
543
544    /// An image built without a Dockerfile carries what the CI images carry, so the env the
545    /// accessor hands out is read back from the committed file rather than restated.
546    #[test]
547    fn the_gcp_env_accessor_is_every_env_the_committed_dockerfile_sets() {
548        let committed = std::fs::read_to_string(committed_path(GCP_DEFAULT_DOCKERFILE)).unwrap();
549        let mut set = Vec::new();
550        let mut in_env = false;
551        for line in committed.lines() {
552            let line = line.trim();
553            let rest = match line.strip_prefix("ENV ") {
554                Some(rest) => rest,
555                None if in_env => line,
556                None => continue,
557            };
558            in_env = rest.ends_with('\\');
559            for pair in rest.trim_end_matches('\\').split_whitespace() {
560                let (name, value) = pair.split_once('=').expect("ENV name=value");
561                set.push((name.to_string(), value.to_string()));
562            }
563        }
564        let accessor: Vec<(String, String)> = gcp_agent_platform_env()
565            .into_iter()
566            .map(|(name, value)| (name.to_string(), value))
567            .collect();
568        assert_eq!(accessor, set);
569    }
570
571    /// Every consumer derives these, so nothing else compares them against a number. The
572    /// setup emitters tell the agent which uid to drop to; if that stops matching the uid the
573    /// image creates, the sandbox starts and every exec fails.
574    #[test]
575    fn the_two_images_carry_the_identities_their_stacks_were_built_against() {
576        assert_eq!(AWS_MICROVM.port, 8971);
577        assert_eq!(AWS_MICROVM.exec_uid, 60000);
578        assert_eq!(AWS_MICROVM.session_root, "/sandbox");
579        assert_eq!(GCP_AGENT_PLATFORM.port, 8080);
580        assert_eq!(GCP_AGENT_PLATFORM.exec_uid, 1000);
581        assert_eq!(GCP_AGENT_PLATFORM.session_root, "/sandbox");
582        for image in [&AWS_MICROVM, &GCP_AGENT_PLATFORM] {
583            assert_ne!(image.exec_uid, 0, "the exec uid must never be root");
584        }
585    }
586
587    /// The ending an image declares and the isolation it claims come off one value, so they
588    /// cannot disagree. The AWS half is rendered at run time and reaches no committed file, which
589    /// is why the whole-file comparison above covers only the GCP side of it.
590    #[test]
591    fn the_ending_an_image_declares_follows_its_isolation() {
592        assert!(!entrypoint(&AWS_MICROVM).contains("USER "));
593        assert!(contract_env(&AWS_MICROVM).contains("ALIEN_SANDBOX_ISOLATION=uid-split"));
594        assert!(entrypoint(&GCP_AGENT_PLATFORM).contains("\nUSER 1000:1000\n"));
595        assert!(contract_env(&GCP_AGENT_PLATFORM).contains("ALIEN_SANDBOX_ISOLATION=platform"));
596    }
597}
598
599/// Root-owned metadata copied from the base image at bundle creation, never from an exec caller.
600#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
601#[serde(rename_all = "camelCase", deny_unknown_fields)]
602pub struct SandboxImageCommand {
603    /// OCI Entrypoint followed by Cmd; empty means there is no image command.
604    pub command: Vec<String>,
605    /// Image environment only, before runtime authorization and provider variables are added.
606    pub env: std::collections::BTreeMap<String, String>,
607    /// OCI WorkingDir, or / if the image does not declare one.
608    pub working_directory: String,
609}
610
611/// Installed outside the command-writable session directory.
612pub const IMAGE_COMMAND_PATH: &str = "/opt/alien/image-command.json";