1pub const AGENT_PATH: &str = "/usr/local/bin/alien-sandbox-agent";
19
20pub const AGENT_PORT: u16 = 8971;
26
27pub const AGENT_MODE: u32 = 0o755;
29
30pub const EXEC_USER: &str = "sandbox";
32
33pub const SESSION_ROOT_MODE: u32 = 0o700;
35
36pub const GCP_AGENT_LOG_FILTER: &str = "info";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Isolation {
46 UidSplit,
49 Platform,
52}
53
54impl Isolation {
55 pub fn env_value(self) -> &'static str {
57 match self {
58 Self::UidSplit => "uid-split",
59 Self::Platform => "platform",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Authorization {
67 Transport,
70 Capability,
72}
73
74impl Authorization {
75 pub fn env_value(self) -> &'static str {
77 match self {
78 Self::Transport => "transport",
79 Self::Capability => "capability",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct SandboxImage {
87 pub exec_uid: u32,
89 pub session_root: &'static str,
91 pub port: u16,
93 pub authorization: Authorization,
94 pub isolation: Isolation,
95}
96
97impl SandboxImage {
98 pub fn exec_gid(&self) -> u32 {
100 self.exec_uid
101 }
102
103 pub fn contract_env_vars(&self) -> [(&'static str, String); 6] {
106 [
107 ("ALIEN_SANDBOX_ROOT", self.session_root.to_string()),
108 ("ALIEN_SANDBOX_PORT", self.port.to_string()),
109 (
110 "ALIEN_SANDBOX_AUTHORIZATION",
111 self.authorization.env_value().to_string(),
112 ),
113 ("ALIEN_SANDBOX_EXEC_UID", self.exec_uid.to_string()),
114 ("ALIEN_SANDBOX_EXEC_GID", self.exec_gid().to_string()),
115 (
116 "ALIEN_SANDBOX_ISOLATION",
117 self.isolation.env_value().to_string(),
118 ),
119 ]
120 }
121
122 pub fn user(&self) -> Option<String> {
125 match self.isolation {
126 Isolation::UidSplit => None,
127 Isolation::Platform => Some(format!("{}:{}", self.exec_uid, self.exec_gid())),
128 }
129 }
130
131 pub fn exposed_port(&self) -> String {
133 format!("{}/tcp", self.port)
134 }
135
136 pub fn passwd_entry(&self) -> String {
138 format!(
139 "{EXEC_USER}:x:{uid}:{gid}::{root}:/sbin/nologin",
140 uid = self.exec_uid,
141 gid = self.exec_gid(),
142 root = self.session_root,
143 )
144 }
145
146 pub fn group_entry(&self) -> String {
148 format!("{EXEC_USER}:x:{gid}:", gid = self.exec_gid())
149 }
150}
151
152pub const AWS_MICROVM: SandboxImage = SandboxImage {
158 exec_uid: 60000,
159 session_root: "/sandbox",
160 port: AGENT_PORT,
161 authorization: Authorization::Transport,
162 isolation: Isolation::UidSplit,
163};
164
165pub const GCP_AGENT_PLATFORM: SandboxImage = SandboxImage {
176 exec_uid: 1000,
177 session_root: "/sandbox",
178 port: 8080,
179 authorization: Authorization::Transport,
180 isolation: Isolation::Platform,
181};
182
183pub fn gcp_agent_platform_env() -> Vec<(&'static str, String)> {
185 let mut env = GCP_AGENT_PLATFORM.contract_env_vars().to_vec();
186 env.push(("RUST_LOG", GCP_AGENT_LOG_FILTER.to_string()));
187 env
188}
189
190pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "public.ecr.aws/docker/library/buildpack-deps:26.04@sha256:159ea382e6fb39e62480ee932113f885f7bd787cd4895fc4dc71aebb175077fd";
195
196pub const DEFAULT_SANDBOX_UV_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.21@sha256:a7aed3216253ee804de3e2d8afa5073baa1a177335345d43845cd4165e43b711";
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct SandboxTool {
202 pub package: Option<&'static str>,
205 pub version_command: &'static str,
207}
208
209pub const DEFAULT_SANDBOX_TOOLS: &[SandboxTool] = &[
214 SandboxTool {
215 package: Some("nftables"),
216 version_command: "/usr/sbin/nft --version",
217 },
218 SandboxTool {
219 package: Some("iptables"),
220 version_command: "iptables-nft --version",
221 },
222 SandboxTool {
223 package: Some("nodejs"),
224 version_command: "node --version",
225 },
226 SandboxTool {
227 package: Some("npm"),
228 version_command: "npm --version",
229 },
230 SandboxTool {
231 package: Some("ripgrep"),
232 version_command: "rg --version",
233 },
234 SandboxTool {
235 package: Some("jq"),
236 version_command: "jq --version",
237 },
238 SandboxTool {
239 package: Some("zip"),
240 version_command: "zip -v",
241 },
242 SandboxTool {
243 package: Some("less"),
244 version_command: "less --version",
245 },
246 SandboxTool {
247 package: Some("nano"),
248 version_command: "nano --version",
249 },
250 SandboxTool {
251 package: Some("vim-tiny"),
252 version_command: "vim.tiny --version",
253 },
254 SandboxTool {
255 package: Some("htop"),
256 version_command: "htop --version",
257 },
258 SandboxTool {
259 package: None,
260 version_command: "uv --version",
261 },
262 SandboxTool {
263 package: None,
264 version_command: "uvx --version",
265 },
266];
267
268pub fn identity_setup(image: &SandboxImage) -> String {
270 format!(
271 r#"RUN printf '{passwd}\n' >> /etc/passwd \
272 && printf '{group}\n' >> /etc/group \
273 && mkdir -p {root} \
274 && chown {uid}:{gid} {root} \
275 && chmod {SESSION_ROOT_MODE:04o} {root}"#,
276 passwd = image.passwd_entry(),
277 group = image.group_entry(),
278 root = image.session_root,
279 uid = image.exec_uid,
280 gid = image.exec_gid(),
281 )
282}
283
284pub fn contract_env(image: &SandboxImage) -> String {
286 let vars: Vec<String> = image
287 .contract_env_vars()
288 .iter()
289 .map(|(name, value)| format!("{name}={value}"))
290 .collect();
291 format!("ENV {}", vars.join(" \\\n "))
292}
293
294pub fn entrypoint(image: &SandboxImage) -> String {
296 let ending = match image.user() {
297 None => String::new(),
298 Some(user) => format!(
299 "# Explicit gid so a runtime that does not read /etc/passwd cannot start the agent in \
300 group 0, which\n# makes the exec drop a privilege crossing whose setgroups needs a \
301 CAP_SETGID this image lacks, so\n# every exec fails.\nUSER {user}\n"
302 ),
303 };
304 format!(
305 "EXPOSE {port}\n{ending}ENTRYPOINT [\"{AGENT_PATH}\"]",
306 port = image.exposed_port()
307 )
308}
309
310#[cfg(test)]
312const GCP_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-agent";
313
314#[cfg(test)]
316const GCP_DEFAULT_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-gcp";
317
318#[cfg(test)]
320const DEFAULT_SANDBOX_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-default";
321
322#[cfg(test)]
324const DEFAULT_SANDBOX_TOOLS_FILE: &str = "docker/sandbox-default-tools.txt";
325
326#[cfg(test)]
329const SANDBOX_FILES_UPDATE: &str = "UPDATE_SANDBOX_AGENT_DOCKERFILE";
330
331#[cfg(test)]
333fn default_sandbox_dockerfile() -> String {
334 let packages: Vec<&str> = DEFAULT_SANDBOX_TOOLS
335 .iter()
336 .filter_map(|tool| tool.package)
337 .collect();
338 format!(
339 r#"# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
340# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
341#
342# Multi-arch tools-only base for the default sandbox images, with no agent. An image that runs the
343# agent adds it and its own ending on top, and a runtime with no in-guest agent runs this as is,
344# so it declares no USER, ENTRYPOINT or ENV.
345
346FROM {DEFAULT_SANDBOX_BASE_IMAGE}
347
348# No version pins: the -updates and -security pockets supersede a pinned version and the arm64
349# ports lag behind, so a pin breaks the build. The published digest is what fixes the versions.
350RUN apt-get update \
351 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
352 {packages} \
353 && rm -rf /var/lib/apt/lists/*
354
355COPY --from={DEFAULT_SANDBOX_UV_IMAGE} /uv /uvx /usr/local/bin/
356"#,
357 packages = packages.join(" "),
358 )
359}
360
361#[cfg(test)]
363fn default_sandbox_tools_list() -> String {
364 let mut list = format!(
365 "# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
366# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
367#
368# One command per tool the default sandbox base ships; each prints a version and exits zero.
369"
370 );
371 for tool in DEFAULT_SANDBOX_TOOLS {
372 list.push_str(tool.version_command);
373 list.push('\n');
374 }
375 list
376}
377
378#[cfg(test)]
380enum GcpBase {
381 Image(&'static str),
382 BuildArg(&'static str),
384}
385
386#[cfg(test)]
388fn gcp_agent_platform_dockerfile() -> String {
389 gcp_dockerfile(
390 "Multi-arch build for the alien-sandbox-agent Docker image",
391 GcpBase::Image("docker.io/chainguard/wolfi-base:latest"),
392 "# git is for the sandboxed command, not the agent, and pulls 24 transitive packages. That cost
393# lands here because this image is the sandbox, with no customer base image underneath to carry it.
394RUN apk add --no-cache git",
395 )
396}
397
398#[cfg(test)]
401fn gcp_default_sandbox_dockerfile() -> String {
402 assert_eq!(
403 GCP_AGENT_PLATFORM.exec_uid, 1000,
404 "the userdel below exists only because Ubuntu's own user holds the exec uid"
405 );
406 gcp_dockerfile(
407 "Multi-arch build for the default GCP sandbox image: the default sandbox base plus the agent",
408 GcpBase::BuildArg("SANDBOX_DEFAULT_BASE"),
409 "# Ubuntu ships `ubuntu` at uid 1000. Appending a second entry for that uid leaves `id` and every
410# tool resolving it to `ubuntu`, so the exec user would not be `sandbox`.
411RUN userdel --remove ubuntu",
412 )
413}
414
415#[cfg(test)]
422fn gcp_dockerfile(title: &str, base: GcpBase, base_setup: &str) -> String {
423 let image = &GCP_AGENT_PLATFORM;
424 let (directive, base_arg, base) = match base {
427 GcpBase::Image(reference) => (String::new(), String::new(), reference.to_string()),
428 GcpBase::BuildArg(name) => (
429 "# check=skip=InvalidDefaultArgInFrom\n".to_string(),
430 format!(
431 "# No default: the base's digest exists only once it is built, so whoever builds \
432 this image\n# passes it. The check directive on line 1 is for this.\nARG {name}\n\n"
433 ),
434 format!("${{{name}}}"),
435 ),
436 };
437 format!(
438 r#"{directive}# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
439# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
440#
441# {title}
442# Run directly as the GCP Agent Platform sandbox; nothing layers on top of it
443
444{base_arg}FROM docker.io/chainguard/wolfi-base:latest AS binary-selector
445
446COPY target/aarch64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-aarch64
447COPY target/x86_64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-x86_64
448
449ARG TARGETARCH
450RUN case "$TARGETARCH" in \
451 amd64) cp /tmp/alien-sandbox-agent-x86_64 /tmp/alien-sandbox-agent ;; \
452 arm64) cp /tmp/alien-sandbox-agent-aarch64 /tmp/alien-sandbox-agent ;; \
453 *) echo "unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
454 esac
455
456FROM {base}
457
458{base_setup}
459
460# Root-owned and unwritable by uid {exec_uid}: the supervised command runs under that uid and must not
461# be able to rewrite its own supervisor.
462COPY --from=binary-selector --chown=0:0 --chmod={AGENT_MODE:04o} \
463 /tmp/alien-sandbox-agent {AGENT_PATH}
464
465# Numeric ids and a plain append rather than adduser, which differs across base distributions.
466# Linux runs a process under a uid with no passwd entry, but tooling inside the sandbox reads one.
467{identity}
468
469# The template carries no env, so the contract lives here, and none of it is optional. transport
470# serves an uncapabilitied request only from a socket `peer::transport_may_serve` cannot trace
471# back to the exec uid, and the agent refuses the mode where /proc/net/tcp is unreadable.
472{env}
473
474# The release build resolves tracing-subscriber once across every package it names, and five of
475# them ask for env-filter, so the agent's fmt::init() has an EnvFilter under it. Unset, that
476# filter discards the startup warning saying this image serves requests without a capability.
477ENV RUST_LOG={GCP_AGENT_LOG_FILTER}
478
479{entrypoint}
480"#,
481 exec_uid = image.exec_uid,
482 identity = identity_setup(image),
483 env = contract_env(image),
484 entrypoint = entrypoint(image),
485 )
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn committed_path(relative: &str) -> std::path::PathBuf {
493 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
494 .join("../..")
495 .join(relative)
496 }
497
498 fn first_difference(committed: &str, rendered: &str) -> String {
501 for (index, (left, right)) in committed.lines().zip(rendered.lines()).enumerate() {
502 if left != right {
503 let line = index + 1;
504 return format!("line {line}: committed {left:?}, contract renders {right:?}");
505 }
506 }
507 format!(
508 "committed has {} lines, the contract renders {}",
509 committed.lines().count(),
510 rendered.lines().count()
511 )
512 }
513
514 #[test]
517 fn the_committed_gcp_dockerfiles_are_what_the_contract_renders() {
518 for (file, rendered) in [
519 (GCP_DOCKERFILE, gcp_agent_platform_dockerfile()),
520 (GCP_DEFAULT_DOCKERFILE, gcp_default_sandbox_dockerfile()),
521 (DEFAULT_SANDBOX_DOCKERFILE, default_sandbox_dockerfile()),
522 (DEFAULT_SANDBOX_TOOLS_FILE, default_sandbox_tools_list()),
523 ] {
524 let path = committed_path(file);
525
526 if std::env::var_os(SANDBOX_FILES_UPDATE).is_some() {
527 std::fs::write(&path, &rendered)
528 .unwrap_or_else(|error| panic!("{} must be writable: {error}", path.display()));
529 continue;
530 }
531
532 let committed = std::fs::read_to_string(&path)
533 .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display()));
534 assert!(
535 committed == rendered,
536 "{file} has drifted from the contract it is rendered from.\n\
537 {}\n\
538 Regenerate it: {SANDBOX_FILES_UPDATE}=1 cargo test -p alien-core --lib sandbox_image",
539 first_difference(&committed, &rendered)
540 );
541 }
542 }
543
544 #[test]
547 fn the_gcp_env_accessor_is_every_env_the_committed_dockerfile_sets() {
548 let committed = std::fs::read_to_string(committed_path(GCP_DEFAULT_DOCKERFILE)).unwrap();
549 let mut set = Vec::new();
550 let mut in_env = false;
551 for line in committed.lines() {
552 let line = line.trim();
553 let rest = match line.strip_prefix("ENV ") {
554 Some(rest) => rest,
555 None if in_env => line,
556 None => continue,
557 };
558 in_env = rest.ends_with('\\');
559 for pair in rest.trim_end_matches('\\').split_whitespace() {
560 let (name, value) = pair.split_once('=').expect("ENV name=value");
561 set.push((name.to_string(), value.to_string()));
562 }
563 }
564 let accessor: Vec<(String, String)> = gcp_agent_platform_env()
565 .into_iter()
566 .map(|(name, value)| (name.to_string(), value))
567 .collect();
568 assert_eq!(accessor, set);
569 }
570
571 #[test]
575 fn the_two_images_carry_the_identities_their_stacks_were_built_against() {
576 assert_eq!(AWS_MICROVM.port, 8971);
577 assert_eq!(AWS_MICROVM.exec_uid, 60000);
578 assert_eq!(AWS_MICROVM.session_root, "/sandbox");
579 assert_eq!(GCP_AGENT_PLATFORM.port, 8080);
580 assert_eq!(GCP_AGENT_PLATFORM.exec_uid, 1000);
581 assert_eq!(GCP_AGENT_PLATFORM.session_root, "/sandbox");
582 for image in [&AWS_MICROVM, &GCP_AGENT_PLATFORM] {
583 assert_ne!(image.exec_uid, 0, "the exec uid must never be root");
584 }
585 }
586
587 #[test]
591 fn the_ending_an_image_declares_follows_its_isolation() {
592 assert!(!entrypoint(&AWS_MICROVM).contains("USER "));
593 assert!(contract_env(&AWS_MICROVM).contains("ALIEN_SANDBOX_ISOLATION=uid-split"));
594 assert!(entrypoint(&GCP_AGENT_PLATFORM).contains("\nUSER 1000:1000\n"));
595 assert!(contract_env(&GCP_AGENT_PLATFORM).contains("ALIEN_SANDBOX_ISOLATION=platform"));
596 }
597}
598
599#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
601#[serde(rename_all = "camelCase", deny_unknown_fields)]
602pub struct SandboxImageCommand {
603 pub command: Vec<String>,
605 pub env: std::collections::BTreeMap<String, String>,
607 pub working_directory: String,
609}
610
611pub const IMAGE_COMMAND_PATH: &str = "/opt/alien/image-command.json";