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 images exist and neither is built the way the other is. AWS renders one per deployment onto
4//! a customer-supplied base image; GCP builds one static image in CI. Nothing at build time reads
5//! the other side, and a value that disagrees is invisible until a session fails: an agent
6//! 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 images render the block that carries them from this
9//! module. The GCP image is committed as generated text because the release workflow builds it
10//! 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/// How an image ends, and the isolation that ending permits.
26///
27/// One value rather than two, because `ALIEN_SANDBOX_ISOLATION` and the trailing `USER` describe
28/// the same decision from opposite sides: an image that asks for `uid-split` under a non-root
29/// `USER` has no privilege left to drop with, and every exec in it fails.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Isolation {
32    /// The agent starts as root and drops to the exec uid before every spawn, so a command can
33    /// never rewrite its own supervisor. The image declares no `USER`.
34    UidSplit,
35    /// The agent starts as the exec uid and supervises commands under it, which is all a runtime
36    /// that refuses a root image can offer. The image ends `USER <uid>:<uid>`.
37    Platform,
38}
39
40impl Isolation {
41    /// The `ALIEN_SANDBOX_ISOLATION` value the agent parses.
42    pub fn env_value(self) -> &'static str {
43        match self {
44            Self::UidSplit => "uid-split",
45            Self::Platform => "platform",
46        }
47    }
48}
49
50/// How the agent decides a caller may be served.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Authorization {
53    /// The connection is the proof. The agent serves an uncapabilitied request only from a socket
54    /// peer it cannot trace back to the exec uid, which keeps the supervised command out.
55    Transport,
56    /// The caller presents a signed token, which is what a network anything can reach requires.
57    Capability,
58}
59
60impl Authorization {
61    /// The `ALIEN_SANDBOX_AUTHORIZATION` value the agent parses.
62    pub fn env_value(self) -> &'static str {
63        match self {
64            Self::Transport => "transport",
65            Self::Capability => "capability",
66        }
67    }
68}
69
70/// The values an image must carry for the agent to run in it.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct SandboxImage {
73    /// Uid and gid the supervised command runs as.
74    pub exec_uid: u32,
75    /// Directory a session's files live under, and the only place `exec_uid` may write.
76    pub session_root: &'static str,
77    /// Port the agent serves, both its own protocol and any lifecycle hooks.
78    pub port: u16,
79    pub authorization: Authorization,
80    pub isolation: Isolation,
81}
82
83/// The Lambda MicroVM image, rendered per deployment onto a customer base image.
84///
85/// The agent runs as root so it can drop to [`SandboxImage::exec_uid`] before every spawn; inside
86/// a MicroVM that is contained by hardware virtualisation, which is the tenant boundary. A
87/// shared-kernel backend must give the agent `CAP_SETUID` instead of root.
88pub const AWS_MICROVM: SandboxImage = SandboxImage {
89    exec_uid: 60000,
90    session_root: "/sandbox",
91    port: AGENT_PORT,
92    authorization: Authorization::Transport,
93    isolation: Isolation::UidSplit,
94};
95
96/// The GCP Agent Platform image, built once in CI and run with nothing layered on top.
97///
98/// 1000 is the conventional first non-root uid, chosen over the 60000 [`AWS_MICROVM`] uses because
99/// that value was never tried against Agent Platform. One uid covers the agent and the commands it
100/// supervises: Agent Platform refuses an image that requires root, so there is no second uid to
101/// drop to.
102///
103/// Agent Platform is only known to serve 8080, on the image and on the declared template port
104/// alike, and whether it honours a declared port or assumes 8080 has never been established.
105/// 8080 is right under either answer; any other number is right under only one.
106pub const GCP_AGENT_PLATFORM: SandboxImage = SandboxImage {
107    exec_uid: 1000,
108    session_root: "/sandbox",
109    port: 8080,
110    authorization: Authorization::Transport,
111    isolation: Isolation::Platform,
112};
113
114/// The `RUN` step creating the exec identity and the session root it owns.
115pub fn identity_setup(image: &SandboxImage) -> String {
116    let SandboxImage {
117        exec_uid,
118        session_root,
119        ..
120    } = *image;
121    format!(
122        r#"RUN printf 'sandbox:x:{exec_uid}:{exec_uid}::{session_root}:/sbin/nologin\n' >> /etc/passwd \
123 && printf 'sandbox:x:{exec_uid}:\n' >> /etc/group \
124 && mkdir -p {session_root} \
125 && chown {exec_uid}:{exec_uid} {session_root} \
126 && chmod 0700 {session_root}"#
127    )
128}
129
130/// The `ENV` block carrying the agent's configuration contract.
131pub fn contract_env(image: &SandboxImage) -> String {
132    let SandboxImage {
133        exec_uid,
134        session_root,
135        port,
136        authorization,
137        isolation,
138    } = *image;
139    format!(
140        r#"ENV ALIEN_SANDBOX_ROOT={session_root} \
141    ALIEN_SANDBOX_PORT={port} \
142    ALIEN_SANDBOX_AUTHORIZATION={authorization} \
143    ALIEN_SANDBOX_EXEC_UID={exec_uid} \
144    ALIEN_SANDBOX_EXEC_GID={exec_uid} \
145    ALIEN_SANDBOX_ISOLATION={isolation}"#,
146        authorization = authorization.env_value(),
147        isolation = isolation.env_value(),
148    )
149}
150
151/// `EXPOSE`, the image's ending, and the `ENTRYPOINT`.
152pub fn entrypoint(image: &SandboxImage) -> String {
153    let ending = match image.isolation {
154        Isolation::UidSplit => String::new(),
155        Isolation::Platform => format!(
156            "# Explicit gid so a runtime that does not read /etc/passwd cannot start the agent in \
157             group 0, which\n# makes the exec drop a privilege crossing whose setgroups needs a \
158             CAP_SETGID this image lacks, so\n# every exec fails.\nUSER {uid}:{uid}\n",
159            uid = image.exec_uid
160        ),
161    };
162    format!(
163        "EXPOSE {port}\n{ending}ENTRYPOINT [\"{AGENT_PATH}\"]",
164        port = image.port
165    )
166}
167
168/// Path of the committed GCP Dockerfile, relative to the repository root.
169#[cfg(test)]
170const GCP_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-agent";
171
172/// Set to regenerate the committed GCP Dockerfile instead of comparing against it.
173#[cfg(test)]
174const GCP_DOCKERFILE_UPDATE: &str = "UPDATE_SANDBOX_AGENT_DOCKERFILE";
175
176/// Renders [`GCP_DOCKERFILE`].
177///
178/// Everything outside the shared block is here because it is true of this image alone: the
179/// `binary-selector` stage, which exists because the release workflow builds one manifest for two
180/// architectures from binaries cross-compiled outside Docker; `git`, which the sandboxed command
181/// needs and no customer base image is underneath to carry; and `RUST_LOG`, which the agent's own
182/// `EnvFilter` needs before it will emit anything.
183#[cfg(test)]
184fn gcp_agent_platform_dockerfile() -> String {
185    let image = &GCP_AGENT_PLATFORM;
186    format!(
187        r#"# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
188# Regenerate with {GCP_DOCKERFILE_UPDATE}=1 in front of that command.
189#
190# Multi-arch build for the alien-sandbox-agent Docker image
191# Run directly as the GCP Agent Platform sandbox; nothing layers on top of it
192
193FROM docker.io/chainguard/wolfi-base:latest AS binary-selector
194
195COPY target/aarch64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-aarch64
196COPY target/x86_64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-x86_64
197
198ARG TARGETARCH
199RUN case "$TARGETARCH" in \
200       amd64)  cp /tmp/alien-sandbox-agent-x86_64 /tmp/alien-sandbox-agent ;; \
201       arm64)  cp /tmp/alien-sandbox-agent-aarch64 /tmp/alien-sandbox-agent ;; \
202       *)      echo "unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
203    esac
204
205FROM docker.io/chainguard/wolfi-base:latest
206
207# git is for the sandboxed command, not the agent, and pulls 24 transitive packages. That cost
208# lands here because this image is the sandbox, with no customer base image underneath to carry it.
209RUN apk add --no-cache git
210
211# Root-owned and unwritable by uid {exec_uid}: the supervised command runs under that uid and must not
212# be able to rewrite its own supervisor.
213COPY --from=binary-selector --chown=0:0 --chmod=0755 \
214     /tmp/alien-sandbox-agent {AGENT_PATH}
215
216# Numeric ids and a plain append rather than adduser, which differs across base distributions.
217# Linux runs a process under a uid with no passwd entry, but tooling inside the sandbox reads one.
218{identity}
219
220# The template carries no env, so the contract lives here, and none of it is optional. transport
221# serves an uncapabilitied request only from a socket `peer::transport_may_serve` cannot trace
222# back to the exec uid, and the agent refuses the mode where /proc/net/tcp is unreadable.
223{env}
224
225# The release build resolves tracing-subscriber once across every package it names, and five of
226# them ask for env-filter, so the agent's fmt::init() has an EnvFilter under it. Unset, that
227# filter discards the startup warning saying this image serves requests without a capability.
228ENV RUST_LOG=info
229
230{entrypoint}
231"#,
232        exec_uid = image.exec_uid,
233        identity = identity_setup(image),
234        env = contract_env(image),
235        entrypoint = entrypoint(image),
236    )
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn committed_path() -> std::path::PathBuf {
244        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
245            .join("../..")
246            .join(GCP_DOCKERFILE)
247    }
248
249    /// Where two texts first part, in terms a reader can act on. `assert_eq!` on a whole
250    /// Dockerfile prints two escaped blobs and says nothing about which line moved.
251    fn first_difference(committed: &str, rendered: &str) -> String {
252        for (index, (left, right)) in committed.lines().zip(rendered.lines()).enumerate() {
253            if left != right {
254                let line = index + 1;
255                return format!("line {line}: committed {left:?}, contract renders {right:?}");
256            }
257        }
258        format!(
259            "committed has {} lines, the contract renders {}",
260            committed.lines().count(),
261            rendered.lines().count()
262        )
263    }
264
265    /// The whole file, not chosen properties. A mutation this comparison cannot see is one that
266    /// leaves the bytes alone, and there is no such mutation.
267    #[test]
268    fn the_committed_gcp_dockerfile_is_what_the_contract_renders() {
269        let path = committed_path();
270        let rendered = gcp_agent_platform_dockerfile();
271
272        if std::env::var_os(GCP_DOCKERFILE_UPDATE).is_some() {
273            std::fs::write(&path, &rendered)
274                .unwrap_or_else(|error| panic!("{} must be writable: {error}", path.display()));
275            return;
276        }
277
278        let committed = std::fs::read_to_string(&path)
279            .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display()));
280        assert!(
281            committed == rendered,
282            "{GCP_DOCKERFILE} has drifted from the contract it is rendered from.\n\
283             {}\n\
284             Regenerate it: {GCP_DOCKERFILE_UPDATE}=1 cargo test -p alien-core --lib sandbox_image",
285            first_difference(&committed, &rendered)
286        );
287    }
288
289    /// The ending an image declares and the isolation it claims come off one value, so they
290    /// cannot disagree. The AWS half is rendered at run time and reaches no committed file, which
291    /// Every consumer now derives these, so nothing else compares them against a number. The
292    /// setup emitters tell the agent which uid to drop to; if that stops matching the uid the
293    /// image creates, the sandbox starts and every exec fails.
294    #[test]
295    fn the_two_images_carry_the_identities_their_stacks_were_built_against() {
296        assert_eq!(AWS_MICROVM.port, 8971);
297        assert_eq!(AWS_MICROVM.exec_uid, 60000);
298        assert_eq!(AWS_MICROVM.session_root, "/sandbox");
299        assert_eq!(GCP_AGENT_PLATFORM.port, 8080);
300        assert_eq!(GCP_AGENT_PLATFORM.exec_uid, 1000);
301        assert_eq!(GCP_AGENT_PLATFORM.session_root, "/sandbox");
302        for image in [&AWS_MICROVM, &GCP_AGENT_PLATFORM] {
303            assert_ne!(image.exec_uid, 0, "the exec uid must never be root");
304        }
305    }
306
307    /// is why the whole-file comparison above covers only the GCP side of it.
308    #[test]
309    fn the_ending_an_image_declares_follows_its_isolation() {
310        assert!(!entrypoint(&AWS_MICROVM).contains("USER "));
311        assert!(contract_env(&AWS_MICROVM).contains("ALIEN_SANDBOX_ISOLATION=uid-split"));
312        assert!(entrypoint(&GCP_AGENT_PLATFORM).contains("\nUSER 1000:1000\n"));
313        assert!(contract_env(&GCP_AGENT_PLATFORM).contains("ALIEN_SANDBOX_ISOLATION=platform"));
314    }
315}