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 exec_gid: u32,
91 pub session_root: &'static str,
93 pub port: u16,
95 pub authorization: Authorization,
96 pub isolation: Isolation,
97}
98
99impl SandboxImage {
100 pub fn exec_gid(&self) -> u32 {
102 self.exec_gid
103 }
104
105 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 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 pub fn exposed_port(&self) -> String {
135 format!("{}/tcp", self.port)
136 }
137
138 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 pub fn group_entry(&self) -> String {
150 format!("{EXEC_USER}:x:{gid}:", gid = self.exec_gid())
151 }
152}
153
154pub 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
168pub 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
187pub 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
194pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "public.ecr.aws/docker/library/buildpack-deps:26.04@sha256:159ea382e6fb39e62480ee932113f885f7bd787cd4895fc4dc71aebb175077fd";
199
200pub const DEFAULT_SANDBOX_UV_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.21@sha256:a7aed3216253ee804de3e2d8afa5073baa1a177335345d43845cd4165e43b711";
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct SandboxTool {
206 pub package: Option<&'static str>,
209 pub version_command: &'static str,
211}
212
213pub 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
272pub 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
291pub 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
301pub 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#[cfg(test)]
319const GCP_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-agent";
320
321#[cfg(test)]
323const GCP_DEFAULT_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-gcp";
324
325#[cfg(test)]
327const DEFAULT_SANDBOX_DOCKERFILE: &str = "docker/Dockerfile.alien-sandbox-default";
328
329#[cfg(test)]
331const DEFAULT_SANDBOX_TOOLS_FILE: &str = "docker/sandbox-default-tools.txt";
332
333#[cfg(test)]
336const SANDBOX_FILES_UPDATE: &str = "UPDATE_SANDBOX_AGENT_DOCKERFILE";
337
338#[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.
357RUN apt-get update \
358 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
359 {packages} \
360 && rm -rf /var/lib/apt/lists/*
361
362COPY --from={DEFAULT_SANDBOX_UV_IMAGE} /uv /uvx /usr/local/bin/
363"#,
364 packages = packages.join(" "),
365 )
366}
367
368#[cfg(test)]
370fn default_sandbox_tools_list() -> String {
371 let mut list = format!(
372 "# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
373# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
374#
375# One command per tool the default sandbox base ships; each prints a version and exits zero.
376"
377 );
378 for tool in DEFAULT_SANDBOX_TOOLS {
379 list.push_str(tool.version_command);
380 list.push('\n');
381 }
382 list
383}
384
385#[cfg(test)]
387enum GcpBase {
388 Image(&'static str),
389 BuildArg(&'static str),
391}
392
393#[cfg(test)]
395fn gcp_agent_platform_dockerfile() -> String {
396 gcp_dockerfile(
397 "Multi-arch build for the alien-sandbox-agent Docker image",
398 GcpBase::Image("docker.io/chainguard/wolfi-base:latest"),
399 "# git is for the sandboxed command, not the agent, and pulls 24 transitive packages. That cost
400# lands here because this image is the sandbox, with no customer base image underneath to carry it.
401RUN apk add --no-cache git",
402 )
403}
404
405#[cfg(test)]
408fn gcp_default_sandbox_dockerfile() -> String {
409 assert_eq!(
410 GCP_AGENT_PLATFORM.exec_uid, 1000,
411 "the userdel below exists only because Ubuntu's own user holds the exec uid"
412 );
413 gcp_dockerfile(
414 "Multi-arch build for the default GCP sandbox image: the default sandbox base plus the agent",
415 GcpBase::BuildArg("SANDBOX_DEFAULT_BASE"),
416 "# Ubuntu ships `ubuntu` at uid 1000. Appending a second entry for that uid leaves `id` and every
417# tool resolving it to `ubuntu`, so the exec user would not be `sandbox`.
418RUN userdel --remove ubuntu",
419 )
420}
421
422#[cfg(test)]
429fn gcp_dockerfile(title: &str, base: GcpBase, base_setup: &str) -> String {
430 let image = &GCP_AGENT_PLATFORM;
431 let (directive, base_arg, base) = match base {
434 GcpBase::Image(reference) => (String::new(), String::new(), reference.to_string()),
435 GcpBase::BuildArg(name) => (
436 "# check=skip=InvalidDefaultArgInFrom\n".to_string(),
437 format!(
438 "# No default: the base's digest exists only once it is built, so whoever builds \
439 this image\n# passes it. The check directive on line 1 is for this.\nARG {name}\n\n"
440 ),
441 format!("${{{name}}}"),
442 ),
443 };
444 format!(
445 r#"{directive}# Generated by `cargo test -p alien-core --lib sandbox_image`. Do not edit by hand.
446# Regenerate with {SANDBOX_FILES_UPDATE}=1 in front of that command.
447#
448# {title}
449# Run directly as the GCP Agent Platform sandbox; nothing layers on top of it
450
451{base_arg}FROM docker.io/chainguard/wolfi-base:latest AS binary-selector
452
453COPY target/aarch64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-aarch64
454COPY target/x86_64-unknown-linux-musl/release/alien-sandbox-agent /tmp/alien-sandbox-agent-x86_64
455
456ARG TARGETARCH
457RUN case "$TARGETARCH" in \
458 amd64) cp /tmp/alien-sandbox-agent-x86_64 /tmp/alien-sandbox-agent ;; \
459 arm64) cp /tmp/alien-sandbox-agent-aarch64 /tmp/alien-sandbox-agent ;; \
460 *) echo "unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
461 esac
462
463FROM {base}
464
465{base_setup}
466
467# Root-owned and unwritable by uid {exec_uid}: the supervised command runs under that uid and must not
468# be able to rewrite its own supervisor.
469COPY --from=binary-selector --chown=0:0 --chmod={AGENT_MODE:04o} \
470 /tmp/alien-sandbox-agent {AGENT_PATH}
471
472# Numeric ids and a plain append rather than adduser, which differs across base distributions.
473# Linux runs a process under a uid with no passwd entry, but tooling inside the sandbox reads one.
474{identity}
475
476# The template carries no env, so the contract lives here, and none of it is optional. transport
477# serves an uncapabilitied request only from a socket `peer::transport_may_serve` cannot trace
478# back to the exec uid, and the agent refuses the mode where /proc/net/tcp is unreadable.
479{env}
480
481# The release build resolves tracing-subscriber once across every package it names, and five of
482# them ask for env-filter, so the agent's fmt::init() has an EnvFilter under it. Unset, that
483# filter discards the startup warning saying this image serves requests without a capability.
484ENV RUST_LOG={GCP_AGENT_LOG_FILTER}
485
486{entrypoint}
487"#,
488 exec_uid = image.exec_uid,
489 identity = identity_setup(image),
490 env = contract_env(image),
491 entrypoint = entrypoint(image),
492 )
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 fn committed_path(relative: &str) -> std::path::PathBuf {
500 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
501 .join("../..")
502 .join(relative)
503 }
504
505 fn first_difference(committed: &str, rendered: &str) -> String {
508 for (index, (left, right)) in committed.lines().zip(rendered.lines()).enumerate() {
509 if left != right {
510 let line = index + 1;
511 return format!("line {line}: committed {left:?}, contract renders {right:?}");
512 }
513 }
514 format!(
515 "committed has {} lines, the contract renders {}",
516 committed.lines().count(),
517 rendered.lines().count()
518 )
519 }
520
521 #[test]
524 fn the_committed_gcp_dockerfiles_are_what_the_contract_renders() {
525 for (file, rendered) in [
526 (GCP_DOCKERFILE, gcp_agent_platform_dockerfile()),
527 (GCP_DEFAULT_DOCKERFILE, gcp_default_sandbox_dockerfile()),
528 (DEFAULT_SANDBOX_DOCKERFILE, default_sandbox_dockerfile()),
529 (DEFAULT_SANDBOX_TOOLS_FILE, default_sandbox_tools_list()),
530 ] {
531 let path = committed_path(file);
532
533 if std::env::var_os(SANDBOX_FILES_UPDATE).is_some() {
534 std::fs::write(&path, &rendered)
535 .unwrap_or_else(|error| panic!("{} must be writable: {error}", path.display()));
536 continue;
537 }
538
539 let committed = std::fs::read_to_string(&path)
540 .unwrap_or_else(|error| panic!("{} must be readable: {error}", path.display()));
541 assert!(
542 committed == rendered,
543 "{file} has drifted from the contract it is rendered from.\n\
544 {}\n\
545 Regenerate it: {SANDBOX_FILES_UPDATE}=1 cargo test -p alien-core --lib sandbox_image",
546 first_difference(&committed, &rendered)
547 );
548 }
549 }
550
551 #[test]
554 fn the_gcp_env_accessor_is_every_env_the_committed_dockerfile_sets() {
555 let committed = std::fs::read_to_string(committed_path(GCP_DEFAULT_DOCKERFILE)).unwrap();
556 let mut set = Vec::new();
557 let mut in_env = false;
558 for line in committed.lines() {
559 let line = line.trim();
560 let rest = match line.strip_prefix("ENV ") {
561 Some(rest) => rest,
562 None if in_env => line,
563 None => continue,
564 };
565 in_env = rest.ends_with('\\');
566 for pair in rest.trim_end_matches('\\').split_whitespace() {
567 let (name, value) = pair.split_once('=').expect("ENV name=value");
568 set.push((name.to_string(), value.to_string()));
569 }
570 }
571 let accessor: Vec<(String, String)> = gcp_agent_platform_env()
572 .into_iter()
573 .map(|(name, value)| (name.to_string(), value))
574 .collect();
575 assert_eq!(accessor, set);
576 }
577
578 #[test]
582 fn the_two_images_carry_the_identities_their_stacks_were_built_against() {
583 assert_eq!(AWS_MICROVM.port, 8971);
584 assert_eq!(AWS_MICROVM.exec_uid, 60000);
585 assert_eq!(AWS_MICROVM.session_root, "/sandbox");
586 assert_eq!(GCP_AGENT_PLATFORM.port, 8080);
587 assert_eq!(GCP_AGENT_PLATFORM.exec_uid, 1000);
588 assert_eq!(GCP_AGENT_PLATFORM.session_root, "/sandbox");
589 for image in [&AWS_MICROVM, &GCP_AGENT_PLATFORM] {
590 assert_ne!(image.exec_uid, 0, "the exec uid must never be root");
591 }
592 }
593
594 #[test]
598 fn the_ending_an_image_declares_follows_its_isolation() {
599 assert!(!entrypoint(&AWS_MICROVM).contains("USER "));
600 assert!(contract_env(&AWS_MICROVM).contains("ALIEN_SANDBOX_ISOLATION=uid-split"));
601 assert!(entrypoint(&GCP_AGENT_PLATFORM).contains("\nUSER 1000:1000\n"));
602 assert!(contract_env(&GCP_AGENT_PLATFORM).contains("ALIEN_SANDBOX_ISOLATION=platform"));
603 }
604}
605
606#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
608#[serde(rename_all = "camelCase", deny_unknown_fields)]
609pub struct SandboxImageCommand {
610 pub command: Vec<String>,
612 pub env: std::collections::BTreeMap<String, String>,
614 pub working_directory: String,
616}
617
618pub const IMAGE_COMMAND_PATH: &str = "/opt/alien/image-command.json";