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