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