1pub const AGENT_PATH: &str = "/usr/local/bin/alien-sandbox-agent";
17
18pub const AGENT_PORT: u16 = 8971;
24
25pub const AGENT_MODE: u32 = 0o755;
27
28pub const EXEC_USER: &str = "sandbox";
30
31pub const SESSION_ROOT_MODE: u32 = 0o700;
33
34pub const GCP_AGENT_LOG_FILTER: &str = "info";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Isolation {
44 UidSplit,
47 Platform,
50}
51
52impl Isolation {
53 pub fn env_value(self) -> &'static str {
55 match self {
56 Self::UidSplit => "uid-split",
57 Self::Platform => "platform",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Authorization {
65 Transport,
68 Capability,
70}
71
72impl Authorization {
73 pub fn env_value(self) -> &'static str {
75 match self {
76 Self::Transport => "transport",
77 Self::Capability => "capability",
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct SandboxImage {
85 pub exec_uid: u32,
87 pub session_root: &'static str,
89 pub port: u16,
91 pub authorization: Authorization,
92 pub isolation: Isolation,
93}
94
95impl SandboxImage {
96 pub fn exec_gid(&self) -> u32 {
98 self.exec_uid
99 }
100
101 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 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 pub fn exposed_port(&self) -> String {
131 format!("{}/tcp", self.port)
132 }
133
134 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 pub fn group_entry(&self) -> String {
146 format!("{EXEC_USER}:x:{gid}:", gid = self.exec_gid())
147 }
148}
149
150pub 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
163pub 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
181pub 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
188pub 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
204pub 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
214pub 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#[cfg(test)]
232const GCP_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-agent";
233
234#[cfg(test)]
236const GCP_DEFAULT_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-gcp";
237
238#[cfg(test)]
240const GCP_DOCKERFILE_UPDATE: &str = "UPDATE_SANDBOX_AGENT_DOCKERFILE";
241
242#[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#[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#[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 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 #[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 #[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 #[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 #[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}