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