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 GCP Dockerfiles are committed as generated text because the release workflow builds
10//! them with `docker build`, which cannot call a Rust function.
11
12/// Path the agent binary is installed at inside every sandbox image.
13///
14/// An image that installs one path and entrypoints another exits before it serves, and the
15/// session times out on connect.
16pub const AGENT_PATH: &str = "/usr/local/bin/alien-sandbox-agent";
17
18/// Port the agent serves unless the platform pins another.
19///
20/// Defined once because two independent copies are a runtime-only failure: the image build places
21/// the agent on one port and the client dials the other, and nothing catches it until a sandbox
22/// hangs. AWS scopes its endpoint token to an explicit port set, so this cannot be discovered.
23pub const AGENT_PORT: u16 = 8971;
24
25/// Mode of the agent binary, owned by `0:0` so the exec uid cannot rewrite its supervisor.
26pub const AGENT_MODE: u32 = 0o755;
27
28/// Name of the exec identity's passwd and group entries.
29pub const EXEC_USER: &str = "sandbox";
30
31/// Mode of the session root, which the exec uid owns.
32pub const SESSION_ROOT_MODE: u32 = 0o700;
33
34/// `RUST_LOG` for a GCP image, which the agent needs set before it logs anything.
35pub const GCP_AGENT_LOG_FILTER: &str = "info";
36
37/// How an image ends, and the isolation that ending permits.
38///
39/// One value rather than two, because `ALIEN_SANDBOX_ISOLATION` and the trailing `USER` describe
40/// the same decision from opposite sides: an image that asks for `uid-split` under a non-root
41/// `USER` has no privilege left to drop with, and every exec in it fails.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Isolation {
44    /// The agent starts as root and drops to the exec uid before every spawn, so a command can
45    /// never rewrite its own supervisor. The image declares no `USER`.
46    UidSplit,
47    /// The agent starts as the exec uid and supervises commands under it, which is all a runtime
48    /// that refuses a root image can offer. The image ends `USER <uid>:<uid>`.
49    Platform,
50}
51
52impl Isolation {
53    /// The `ALIEN_SANDBOX_ISOLATION` value the agent parses.
54    pub fn env_value(self) -> &'static str {
55        match self {
56            Self::UidSplit => "uid-split",
57            Self::Platform => "platform",
58        }
59    }
60}
61
62/// How the agent decides a caller may be served.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Authorization {
65    /// The connection is the proof. The agent serves an uncapabilitied request only from a socket
66    /// peer it cannot trace back to the exec uid, which keeps the supervised command out.
67    Transport,
68    /// The caller presents a signed token, which is what a network anything can reach requires.
69    Capability,
70}
71
72impl Authorization {
73    /// The `ALIEN_SANDBOX_AUTHORIZATION` value the agent parses.
74    pub fn env_value(self) -> &'static str {
75        match self {
76            Self::Transport => "transport",
77            Self::Capability => "capability",
78        }
79    }
80}
81
82/// The values an image must carry for the agent to run in it.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct SandboxImage {
85    /// Uid and gid the supervised command runs as.
86    pub exec_uid: u32,
87    /// Directory a session's files live under, and the only place `exec_uid` may write.
88    pub session_root: &'static str,
89    /// Port the agent serves, both its own protocol and any lifecycle hooks.
90    pub port: u16,
91    pub authorization: Authorization,
92    pub isolation: Isolation,
93}
94
95impl SandboxImage {
96    /// Gid the supervised command runs as, always equal to the exec uid.
97    pub fn exec_gid(&self) -> u32 {
98        self.exec_uid
99    }
100
101    /// The agent's configuration contract as `(name, value)` pairs, in the order the `ENV` block
102    /// lists them.
103    pub fn contract_env_vars(&self) -> [(&'static str, String); 6] {
104        [
105            ("ALIEN_SANDBOX_ROOT", self.session_root.to_string()),
106            ("ALIEN_SANDBOX_PORT", self.port.to_string()),
107            (
108                "ALIEN_SANDBOX_AUTHORIZATION",
109                self.authorization.env_value().to_string(),
110            ),
111            ("ALIEN_SANDBOX_EXEC_UID", self.exec_uid.to_string()),
112            ("ALIEN_SANDBOX_EXEC_GID", self.exec_gid().to_string()),
113            (
114                "ALIEN_SANDBOX_ISOLATION",
115                self.isolation.env_value().to_string(),
116            ),
117        ]
118    }
119
120    /// The `uid:gid` the image runs as, or `None` when it declares no user and starts as root.
121    /// Why the gid is explicit is in the `USER` comment [`entrypoint`] renders.
122    pub fn user(&self) -> Option<String> {
123        match self.isolation {
124            Isolation::UidSplit => None,
125            Isolation::Platform => Some(format!("{}:{}", self.exec_uid, self.exec_gid())),
126        }
127    }
128
129    /// The port the image exposes, as an OCI `ExposedPorts` key.
130    pub fn exposed_port(&self) -> String {
131        format!("{}/tcp", self.port)
132    }
133
134    /// The exec identity's `/etc/passwd` line, without a trailing newline.
135    pub fn passwd_entry(&self) -> String {
136        format!(
137            "{EXEC_USER}:x:{uid}:{gid}::{root}:/sbin/nologin",
138            uid = self.exec_uid,
139            gid = self.exec_gid(),
140            root = self.session_root,
141        )
142    }
143
144    /// The exec identity's `/etc/group` line, without a trailing newline.
145    pub fn group_entry(&self) -> String {
146        format!("{EXEC_USER}:x:{gid}:", gid = self.exec_gid())
147    }
148}
149
150/// The Lambda MicroVM image, rendered per deployment onto a customer base image.
151///
152/// The agent runs as root so it can drop to [`SandboxImage::exec_uid`] before every spawn; inside
153/// a MicroVM that is contained by hardware virtualisation, which is the tenant boundary. A
154/// shared-kernel backend must give the agent `CAP_SETUID` instead of root.
155pub const AWS_MICROVM: SandboxImage = SandboxImage {
156    exec_uid: 60000,
157    session_root: "/sandbox",
158    port: AGENT_PORT,
159    authorization: Authorization::Transport,
160    isolation: Isolation::UidSplit,
161};
162
163/// The GCP Agent Platform image, built once in CI and run with nothing layered on top.
164///
165/// 1000 is the conventional first non-root uid, chosen over the 60000 [`AWS_MICROVM`] uses because
166/// that value was never tried against Agent Platform. One uid covers the agent and the commands it
167/// supervises: Agent Platform refuses an image that requires root, so there is no second uid to
168/// drop to.
169///
170/// Agent Platform is only known to serve 8080, on the image and on the declared template port
171/// alike, and whether it honours a declared port or assumes 8080 has never been established.
172/// 8080 is right under either answer; any other number is right under only one.
173pub const GCP_AGENT_PLATFORM: SandboxImage = SandboxImage {
174    exec_uid: 1000,
175    session_root: "/sandbox",
176    port: 8080,
177    authorization: Authorization::Transport,
178    isolation: Isolation::Platform,
179};
180
181/// Every variable a GCP Agent Platform image sets: the contract, then `RUST_LOG`.
182pub fn gcp_agent_platform_env() -> Vec<(&'static str, String)> {
183    let mut env = GCP_AGENT_PLATFORM.contract_env_vars().to_vec();
184    env.push(("RUST_LOG", GCP_AGENT_LOG_FILTER.to_string()));
185    env
186}
187
188/// The `RUN` step creating the exec identity and the session root it owns.
189pub fn identity_setup(image: &SandboxImage) -> String {
190    format!(
191        r#"RUN printf '{passwd}\n' >> /etc/passwd \
192 && printf '{group}\n' >> /etc/group \
193 && mkdir -p {root} \
194 && chown {uid}:{gid} {root} \
195 && chmod {SESSION_ROOT_MODE:04o} {root}"#,
196        passwd = image.passwd_entry(),
197        group = image.group_entry(),
198        root = image.session_root,
199        uid = image.exec_uid,
200        gid = image.exec_gid(),
201    )
202}
203
204/// The `ENV` block carrying the agent's configuration contract.
205pub fn contract_env(image: &SandboxImage) -> String {
206    let vars: Vec<String> = image
207        .contract_env_vars()
208        .iter()
209        .map(|(name, value)| format!("{name}={value}"))
210        .collect();
211    format!("ENV {}", vars.join(" \\\n    "))
212}
213
214/// `EXPOSE`, the image's ending, and the `ENTRYPOINT`.
215pub fn entrypoint(image: &SandboxImage) -> String {
216    let ending = match image.user() {
217        None => String::new(),
218        Some(user) => format!(
219            "# Explicit gid so a runtime that does not read /etc/passwd cannot start the agent in \
220             group 0, which\n# makes the exec drop a privilege crossing whose setgroups needs a \
221             CAP_SETGID this image lacks, so\n# every exec fails.\nUSER {user}\n"
222        ),
223    };
224    format!(
225        "EXPOSE {port}\n{ending}ENTRYPOINT [\"{AGENT_PATH}\"]",
226        port = image.exposed_port()
227    )
228}
229
230/// Path of the committed minimal wolfi GCP Dockerfile, relative to the repository root.
231#[cfg(test)]
232const GCP_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-agent";
233
234/// Path of the committed default GCP sandbox Dockerfile, relative to the repository root.
235#[cfg(test)]
236const GCP_DEFAULT_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-gcp";
237
238/// Set to regenerate the committed GCP Dockerfiles instead of comparing against them.
239#[cfg(test)]
240const GCP_DOCKERFILE_UPDATE: &str = "UPDATE_SANDBOX_AGENT_DOCKERFILE";
241
242/// Renders [`GCP_DOCKERFILE`], the minimal wolfi image.
243#[cfg(test)]
244fn gcp_agent_platform_dockerfile() -> String {
245    gcp_dockerfile(
246        "Multi-arch build for the alien-sandbox-agent Docker image",
247        "docker.io/chainguard/wolfi-base:latest",
248        "# git is for the sandboxed command, not the agent, and pulls 24 transitive packages. That cost
249# lands here because this image is the sandbox, with no customer base image underneath to carry it.
250RUN apk add --no-cache git",
251    )
252}
253
254/// Renders [`GCP_DEFAULT_DOCKERFILE`], the published default GCP sandbox image: the agent on
255/// `buildpack-deps`, a full Ubuntu build toolchain.
256#[cfg(test)]
257fn gcp_default_sandbox_dockerfile() -> String {
258    assert_eq!(
259        GCP_AGENT_PLATFORM.exec_uid, 1000,
260        "the userdel below exists only because Ubuntu's own user holds the exec uid"
261    );
262    gcp_dockerfile(
263        "Multi-arch build for the default GCP sandbox image: buildpack-deps plus the agent",
264        "docker.io/library/buildpack-deps:26.04",
265        "# Ubuntu ships `ubuntu` at uid 1000. Appending a second entry for that uid leaves `id` and every
266# tool resolving it to `ubuntu`, so the exec user would not be `sandbox`.
267RUN userdel --remove ubuntu",
268    )
269}
270
271/// Renders a GCP Dockerfile onto `base`, with `base_setup` run as root before the agent lands.
272///
273/// Everything outside the shared block is here because it is true of the GCP images alone: the
274/// `binary-selector` stage, which exists because the release workflow builds one manifest for two
275/// architectures from binaries cross-compiled outside Docker; and `RUST_LOG`, which the agent's own
276/// `EnvFilter` needs before it will emit anything.
277#[cfg(test)]
278fn gcp_dockerfile(title: &str, base: &str, base_setup: &str) -> String {
279    let image = &GCP_AGENT_PLATFORM;
280    format!(
281        r#"# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
282# Regenerate with {GCP_DOCKERFILE_UPDATE}=1 in front of that command.
283#
284# {title}
285# Run directly as the GCP Agent Platform sandbox; nothing layers on top of it
286
287FROM docker.io/chainguard/wolfi-base:latest AS binary-selector
288
289COPY target/aarch64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-aarch64
290COPY target/x86_64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-x86_64
291
292ARG TARGETARCH
293RUN case "$TARGETARCH" in \
294       amd64)  cp /tmp/alien-sandbox-agent-x86_64 /tmp/alien-sandbox-agent ;; \
295       arm64)  cp /tmp/alien-sandbox-agent-aarch64 /tmp/alien-sandbox-agent ;; \
296       *)      echo "unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
297    esac
298
299FROM {base}
300
301{base_setup}
302
303# Root-owned and unwritable by uid {exec_uid}: the supervised command runs under that uid and must not
304# be able to rewrite its own supervisor.
305COPY --from=binary-selector --chown=0:0 --chmod={AGENT_MODE:04o} \
306     /tmp/alien-sandbox-agent {AGENT_PATH}
307
308# Numeric ids and a plain append rather than adduser, which differs across base distributions.
309# Linux runs a process under a uid with no passwd entry, but tooling inside the sandbox reads one.
310{identity}
311
312# The template carries no env, so the contract lives here, and none of it is optional. transport
313# serves an uncapabilitied request only from a socket `peer::transport_may_serve` cannot trace
314# back to the exec uid, and the agent refuses the mode where /proc/net/tcp is unreadable.
315{env}
316
317# The release build resolves tracing-subscriber once across every package it names, and five of
318# them ask for env-filter, so the agent's fmt::init() has an EnvFilter under it. Unset, that
319# filter discards the startup warning saying this image serves requests without a capability.
320ENV RUST_LOG={GCP_AGENT_LOG_FILTER}
321
322{entrypoint}
323"#,
324        exec_uid = image.exec_uid,
325        identity = identity_setup(image),
326        env = contract_env(image),
327        entrypoint = entrypoint(image),
328    )
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn committed_path(relative: &str) -> std::path::PathBuf {
336        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
337            .join("../..")
338            .join(relative)
339    }
340
341    /// Where two texts first part, in terms a reader can act on. `assert_eq!` on a whole
342    /// Dockerfile prints two escaped blobs and says nothing about which line moved.
343    fn first_difference(committed: &str, rendered: &str) -> String {
344        for (index, (left, right)) in committed.lines().zip(rendered.lines()).enumerate() {
345            if left != right {
346                let line = index + 1;
347                return format!("line {line}: committed {left:?}, contract renders {right:?}");
348            }
349        }
350        format!(
351            "committed has {} lines, the contract renders {}",
352            committed.lines().count(),
353            rendered.lines().count()
354        )
355    }
356
357    /// The whole file, not chosen properties. A mutation this comparison cannot see is one that
358    /// leaves the bytes alone, and there is no such mutation.
359    #[test]
360    fn the_committed_gcp_dockerfiles_are_what_the_contract_renders() {
361        for (file, rendered) in [
362            (GCP_DOCKERFILE, gcp_agent_platform_dockerfile()),
363            (GCP_DEFAULT_DOCKERFILE, gcp_default_sandbox_dockerfile()),
364        ] {
365            let path = committed_path(file);
366
367            if std::env::var_os(GCP_DOCKERFILE_UPDATE).is_some() {
368                std::fs::write(&path, &rendered)
369                    .unwrap_or_else(|error| panic!("{} must be writable: {error}", path.display()));
370                continue;
371            }
372
373            let committed = std::fs::read_to_string(&path)
374                .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display()));
375            assert!(
376                committed == rendered,
377                "{file} has drifted from the contract it is rendered from.\n\
378                 {}\n\
379                 Regenerate it: {GCP_DOCKERFILE_UPDATE}=1 cargo test -p alien-core --lib sandbox_image",
380                first_difference(&committed, &rendered)
381            );
382        }
383    }
384
385    /// An image built without a Dockerfile carries what the CI images carry, so the env the
386    /// accessor hands out is read back from the committed file rather than restated.
387    #[test]
388    fn the_gcp_env_accessor_is_every_env_the_committed_dockerfile_sets() {
389        let committed = std::fs::read_to_string(committed_path(GCP_DEFAULT_DOCKERFILE)).unwrap();
390        let mut set = Vec::new();
391        let mut in_env = false;
392        for line in committed.lines() {
393            let line = line.trim();
394            let rest = match line.strip_prefix("ENV ") {
395                Some(rest) => rest,
396                None if in_env => line,
397                None => continue,
398            };
399            in_env = rest.ends_with('\\');
400            for pair in rest.trim_end_matches('\\').split_whitespace() {
401                let (name, value) = pair.split_once('=').expect("ENV name=value");
402                set.push((name.to_string(), value.to_string()));
403            }
404        }
405        let accessor: Vec<(String, String)> = gcp_agent_platform_env()
406            .into_iter()
407            .map(|(name, value)| (name.to_string(), value))
408            .collect();
409        assert_eq!(accessor, set);
410    }
411
412    /// Every consumer derives these, so nothing else compares them against a number. The
413    /// setup emitters tell the agent which uid to drop to; if that stops matching the uid the
414    /// image creates, the sandbox starts and every exec fails.
415    #[test]
416    fn the_two_images_carry_the_identities_their_stacks_were_built_against() {
417        assert_eq!(AWS_MICROVM.port, 8971);
418        assert_eq!(AWS_MICROVM.exec_uid, 60000);
419        assert_eq!(AWS_MICROVM.session_root, "/sandbox");
420        assert_eq!(GCP_AGENT_PLATFORM.port, 8080);
421        assert_eq!(GCP_AGENT_PLATFORM.exec_uid, 1000);
422        assert_eq!(GCP_AGENT_PLATFORM.session_root, "/sandbox");
423        for image in [&AWS_MICROVM, &GCP_AGENT_PLATFORM] {
424            assert_ne!(image.exec_uid, 0, "the exec uid must never be root");
425        }
426    }
427
428    /// The ending an image declares and the isolation it claims come off one value, so they
429    /// cannot disagree. The AWS half is rendered at run time and reaches no committed file, which
430    /// is why the whole-file comparison above covers only the GCP side of it.
431    #[test]
432    fn the_ending_an_image_declares_follows_its_isolation() {
433        assert!(!entrypoint(&AWS_MICROVM).contains("USER "));
434        assert!(contract_env(&AWS_MICROVM).contains("ALIEN_SANDBOX_ISOLATION=uid-split"));
435        assert!(entrypoint(&GCP_AGENT_PLATFORM).contains("\nUSER 1000:1000\n"));
436        assert!(contract_env(&GCP_AGENT_PLATFORM).contains("ALIEN_SANDBOX_ISOLATION=platform"));
437    }
438}