From cd1e193a72801905c6b7cbcf9c3d4ae13c2f4d8f Mon Sep 17 00:00:00 2001 From: Nick Hynes Date: Tue, 24 Mar 2026 13:38:35 +0800 Subject: [PATCH 1/2] Add more sandboxing to direct mode --- Cargo.lock | 2 + README.md | 10 +- cli/Cargo.toml | 1 + cli/README.md | 4 +- cli/src/main.rs | 439 ++++++++++++++++++++---- cli/tests/compile_outputs.rs | 157 +++++++++ cli/tests/direct_smoke.rs | 236 +++++++++++++ compiler/manifest/README.md | 10 + compiler/manifest/src/manifest/tests.rs | 66 ++++ compiler/manifest/src/schema.rs | 32 ++ compiler/scenario/src/program.rs | 20 ++ compiler/src/linker/program_lowering.rs | 1 + compiler/src/targets/direct/mod.rs | 96 +++++- runtime/helper/Cargo.toml | 1 + runtime/helper/src/lib.rs | 60 +++- runtime/helper/src/main.rs | 397 ++++++++++++++++++++- 16 files changed, 1449 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c022380..78399e9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,7 @@ version = "0.0.0" dependencies = [ "amber-compiler", "amber-config", + "amber-helper", "amber-images", "amber-manifest", "amber-mesh", @@ -202,6 +203,7 @@ dependencies = [ "base64", "jsonschema", "libc", + "linux-raw-sys", "serde", "serde_json", "signal-hook", diff --git a/README.md b/README.md index c725f8c8..6333cb3c 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,11 @@ amber run /tmp/amber-direct Direct output only supports components that use `program.path`. `amber run` for direct output requires a local sandbox backend: -- Linux: `bwrap` and `slirp4netns` +- Linux: `bwrap`, `slirp4netns`, and a Landlock-enabled kernel - macOS: `/usr/bin/sandbox-exec` Current enforcement notes: -- Direct/native on Linux has the strongest capability mediation today: Amber runs each component behind a sidecar/router, isolates sidecar networking, joins the component into that namespace, shapes the filesystem with curated read-only mounts plus explicit writable storage, and drops all Linux capabilities for Amber-owned sidecars. +- Direct/native on Linux has the strongest capability mediation today: Amber runs each component behind a sidecar/router, isolates sidecar networking, joins the component into that namespace, shapes the filesystem with curated read-only mounts plus explicit writable storage, launches component programs through `amber-helper`, applies fixed seccomp and Landlock hardening inside that shaped view, and drops all Linux capabilities for Amber-owned sidecars. - Docker Compose and Kubernetes now default generated containers to non-escalating privilege settings, run Amber-owned internal routers/provisioners non-root where their images already guarantee it, make those internal root filesystems read-only where possible, and reject external slot targets that resolve to loopback or link-local IPs. - Docker Compose and Kubernetes do not yet transparently redirect all arbitrary container egress through the router. Amber strongly mediates declared capability paths, but shared pod/service networking still means generic outbound traffic is not yet fully non-bypassable on those backends. @@ -266,7 +266,11 @@ amber run /tmp/direct-out ``` Direct output requires `program.path` with an explicit absolute path or a manifest-relative path -like `./bin/server`; it does not search `PATH`. +like `./bin/server`; it does not search `PATH`. By default, direct mode preserves the same ambient +read-only access to the component's local source tree that it historically exposed. Add +`program.reads` to replace that legacy source-tree read access with explicit manifest-relative or +absolute read-only paths instead. Amber still keeps the executable support path and platform +runtime defaults readable so the process can start. ### Compile + run VM diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e3dfff92..4e4230fb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] amber-compiler = { workspace = true } amber-config = { workspace = true } +amber-helper = { path = "../runtime/helper" } amber-manifest = { workspace = true } amber-mesh = { workspace = true } amber-proxy = { workspace = true } diff --git a/cli/README.md b/cli/README.md index 07866e2c..3f05e446 100644 --- a/cli/README.md +++ b/cli/README.md @@ -12,8 +12,10 @@ Command-line front-end for the compiler. It resolves a root manifest, runs compi - Emit bundle directories via `--bundle` when the input is a manifest or bundle; Scenario IR inputs do not carry manifest source bytes, so `--bundle` is not available there. - Run compiled direct and VM artifacts via `amber run `. - - Direct mode requires a local sandbox backend: `bwrap` plus `slirp4netns` on Linux, or `/usr/bin/sandbox-exec` on macOS. + - Direct mode requires a local sandbox backend: `bwrap`, `slirp4netns`, and a Landlock-enabled kernel on Linux, or `/usr/bin/sandbox-exec` on macOS. - Direct mode only supports explicit `program.path` executables; it does not resolve bare program names through `PATH`. + - Linux direct mode launches component programs through `amber-helper`, which applies fixed seccomp and Landlock hardening inside Amber's shaped filesystem view. + - `program.reads` replaces the legacy source-tree read access for `program.path` components with explicit manifest-relative or absolute read-only paths. Amber still keeps the executable support path and platform runtime defaults readable so the process can start. - VM mode also accepts `vm-plan.json` and depends on local QEMU tooling. - Surface the manifest README via `amber docs manifest`. - Surface embedded project docs via `amber docs readme`, `amber docs manifest`, and diff --git a/cli/src/main.rs b/cli/src/main.rs index d40a36e5..179cc78d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -36,6 +36,8 @@ use amber_compiler::{ }, }; use amber_config::{self as config, CONFIG_ENV_PREFIX}; +#[cfg(target_os = "linux")] +use amber_helper::{DIRECT_HARDENING_ENV, DirectHardeningPlan}; use amber_manifest::ManifestRef; use amber_mesh::{ InboundTarget, MESH_CONFIG_FILENAME, MESH_IDENTITY_FILENAME, MESH_PROVISION_PLAN_VERSION, @@ -207,7 +209,7 @@ Examples: amber run /tmp/amber-direct --storage-root /srv/amber-state Runtime requirements: - Linux: `bwrap` and `slirp4netns` + Linux: `bwrap`, `slirp4netns`, and a Landlock-enabled kernel macOS: `/usr/bin/sandbox-exec` or QEMU/HVF"; const PROXY_LONG_ABOUT: &str = "\ @@ -1759,16 +1761,53 @@ fn component_program_spec( runtime_addresses: &DirectRuntimeAddressPlan, runtime_state: &DirectRuntimeState, ) -> Result { - let source_dir = component_source_dir(component)?; let work_dir = runtime_root.join(&component.program.work_dir); let mut writable_dirs = vec![work_dir.clone()]; - let read_only_mounts = component_program_read_only_mounts(component, source_dir.as_deref())?; + let read_only_mounts = component_program_read_only_mounts(component)?; let bind_mounts = direct_storage_bind_mounts(storage_root, component)?; match &component.program.execution { DirectProgramExecutionPlan::Direct { entrypoint, env } => { let (program, args) = split_entrypoint(entrypoint)?; let program = ensure_absolute_direct_program_path(&program, component.moniker.as_str())?; + #[cfg(target_os = "linux")] + { + let helper_binary = resolve_runtime_binary("amber-helper")?; + let mut helper_env = BTreeMap::new(); + insert_helper_payload( + &mut helper_env, + "AMBER_RESOLVED_ENTRYPOINT_B64", + entrypoint, + "direct program entrypoint", + )?; + insert_helper_payload( + &mut helper_env, + "AMBER_RESOLVED_ENV_B64", + env, + "direct program environment", + )?; + append_linux_direct_hardening_env( + &mut helper_env, + &read_only_mounts, + &writable_dirs, + &[], + &bind_mounts, + )?; + return Ok(ProcessSpec { + name: component.program.log_name.clone(), + program: helper_binary.to_string(), + args: vec!["run".to_string()], + env: helper_env, + work_dir, + drop_all_caps: false, + read_only_mounts, + writable_dirs, + bind_dirs: Vec::new(), + bind_mounts, + hidden_paths: Vec::new(), + network: ProcessNetwork::Host, + }); + } Ok(ProcessSpec { name: component.program.log_name.clone(), program, @@ -1819,6 +1858,14 @@ fn component_program_spec( &env, )?); } + #[cfg(target_os = "linux")] + append_linux_direct_hardening_env( + &mut env, + &read_only_mounts, + &writable_dirs, + &[], + &bind_mounts, + )?; Ok(ProcessSpec { name: component.program.log_name.clone(), program: helper_binary.to_string(), @@ -1837,6 +1884,24 @@ fn component_program_spec( } } +#[cfg(target_os = "linux")] +fn insert_helper_payload( + env_map: &mut BTreeMap, + key: &str, + value: &T, + description: &str, +) -> Result<()> { + env_map.insert(key.to_string(), encode_helper_payload(value, description)?); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn encode_helper_payload(value: &T, description: &str) -> Result { + let bytes = serde_json::to_vec(value) + .map_err(|err| miette::miette!("failed to serialize {description}: {err}"))?; + Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) +} + fn direct_storage_bind_mounts( storage_root: &Path, component: &DirectComponentPlan, @@ -2006,54 +2071,35 @@ fn split_entrypoint(entrypoint: &[String]) -> Result<(String, Vec)> { Ok((program.clone(), entrypoint[1..].to_vec())) } -fn component_source_dir(component: &DirectComponentPlan) -> Result> { - let Some(raw) = component.source_dir.as_deref() else { - return Ok(None); - }; - let path = PathBuf::from(raw); - if !path.is_absolute() { - return Err(miette::miette!( - "direct plan has non-absolute source directory {} for component {}", - path.display(), - component.moniker - )); - } - Ok(Some(path)) -} - fn component_program_read_only_mounts( component: &DirectComponentPlan, - source_dir: Option<&Path>, ) -> Result> { let mut mounts = BTreeMap::::new(); - if let Some(source_dir) = source_dir - && source_dir.is_absolute() - { - mounts.insert( - source_dir.to_path_buf(), - ReadOnlyMount { - source: source_dir.to_path_buf(), - dest: source_dir.to_path_buf(), - }, - ); + for path in &component.program.read_only_paths { + insert_same_path_read_only_mount(&mut mounts, PathBuf::from(path)); } - let Some(program_path) = component_execution_program_path(component)? else { - return Ok(mounts.into_values().collect()); - }; - let program_path = - ensure_absolute_direct_program_path(&program_path, component.moniker.as_str())?; - let program_path = Path::new(&program_path); - if let Some(parent) = program_path.parent() { - mounts.entry(parent.to_path_buf()).or_insert(ReadOnlyMount { - source: parent.to_path_buf(), - dest: parent.to_path_buf(), - }); + if let Some(program_path) = component_execution_program_path(component)? { + let program = + ensure_absolute_direct_program_path(&program_path, component.moniker.as_str())?; + if let Some(path) = program_support_path(program.as_str()) { + insert_same_path_read_only_mount(&mut mounts, path); + } } Ok(mounts.into_values().collect()) } +fn insert_same_path_read_only_mount(mounts: &mut BTreeMap, path: PathBuf) { + if !path.is_absolute() { + return; + } + mounts.entry(path.clone()).or_insert(ReadOnlyMount { + source: path.clone(), + dest: path, + }); +} + fn component_execution_program_path(component: &DirectComponentPlan) -> Result> { match &component.program.execution { DirectProgramExecutionPlan::Direct { entrypoint, .. } => Ok(entrypoint.first().cloned()), @@ -2146,6 +2192,22 @@ fn render_template_string_literal(parts: &[TemplatePart]) -> Result { Ok(out) } +#[cfg(target_os = "linux")] +fn append_linux_direct_hardening_env( + env_map: &mut BTreeMap, + read_only_mounts: &[ReadOnlyMount], + writable_dirs: &[PathBuf], + bind_dirs: &[PathBuf], + bind_mounts: &[BindMount], +) -> Result<()> { + let plan = linux_direct_hardening_plan(read_only_mounts, writable_dirs, bind_dirs, bind_mounts); + env_map.insert( + DIRECT_HARDENING_ENV.to_string(), + encode_helper_payload(&plan, "direct hardening plan")?, + ); + Ok(()) +} + fn ensure_absolute_direct_program_path(program: &str, component_moniker: &str) -> Result { if Path::new(program).is_absolute() { return Ok(program.to_string()); @@ -3233,6 +3295,21 @@ const LINUX_DEFAULT_READ_ONLY_PATHS: &[&str] = &[ const LINUX_DEFAULT_DEVICE_PATHS: &[&str] = &["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"]; +#[cfg(target_os = "macos")] +const MACOS_DEFAULT_READ_ONLY_PATHS: &[&str] = &[ + "/usr", + "/bin", + "/sbin", + "/System", + "/Library", + "/opt", + "/nix/store", + "/etc", + "/private/etc", + "/var/db/timezone", + "/dev", +]; + fn configure_managed_command_env( command: &mut TokioCommand, work_dir: &Path, @@ -3272,11 +3349,18 @@ fn linux_default_read_only_mounts() -> Vec { #[cfg(target_os = "linux")] fn linux_program_support_mount(program: &str) -> Option { + linux_same_path_read_only_mount(program_support_path(program)?.as_path()) +} + +fn program_support_path(program: &str) -> Option { let program = Path::new(program); if !program.is_absolute() { return None; } - linux_same_path_read_only_mount(program.parent()?) + Some(match program.parent() { + Some(parent) if parent != Path::new("/") => parent.to_path_buf(), + _ => program.to_path_buf(), + }) } #[cfg(target_os = "linux")] @@ -3305,6 +3389,58 @@ fn linux_normalize_read_only_mount(mount: &ReadOnlyMount) -> Option DirectHardeningPlan { + let mut read_only_paths = BTreeSet::new(); + for mount in linux_default_read_only_mounts() { + read_only_paths.insert(mount.dest); + } + read_only_paths.insert(PathBuf::from("/proc")); + read_only_paths.insert(PathBuf::from("/dev")); + for mount in read_only_mounts { + if let Some(mount) = linux_normalize_read_only_mount(mount) { + read_only_paths.insert(mount.dest); + } + } + + let mut writable_paths = BTreeSet::from([ + PathBuf::from("/tmp"), + PathBuf::from("/run"), + PathBuf::from("/dev/shm"), + ]); + for path in LINUX_DEFAULT_DEVICE_PATHS { + writable_paths.insert(PathBuf::from(path)); + } + for dir in writable_dirs { + if dir.is_absolute() { + writable_paths.insert(normalize_linux_writable_dir(dir)); + } + } + for dir in bind_dirs { + if dir.is_absolute() { + writable_paths.insert(normalize_linux_writable_dir(dir)); + } + } + for mount in bind_mounts { + if mount.dest.is_absolute() { + writable_paths.insert(mount.dest.clone()); + } + } + + DirectHardeningPlan { + read_only_paths: read_only_paths + .into_iter() + .filter(|path| !writable_paths.contains(path)) + .collect(), + writable_paths: writable_paths.into_iter().collect(), + } +} + #[cfg(target_os = "linux")] fn normalize_linux_writable_dir(path: &Path) -> PathBuf { if !path.is_absolute() { @@ -3381,16 +3517,8 @@ fn linux_mount_dest_dirs(path: &Path, include_self: bool) -> Vec { #[cfg(target_os = "macos")] fn render_seatbelt_profile(spec: &ProcessSpec) -> String { - let mut allowed = BTreeSet::new(); - insert_seatbelt_path_variants(&mut allowed, &spec.work_dir); - allowed.insert("/tmp".to_string()); - allowed.insert("/private/tmp".to_string()); - for dir in &spec.writable_dirs { - insert_seatbelt_path_variants(&mut allowed, dir); - } - for dir in &spec.bind_dirs { - insert_seatbelt_path_variants(&mut allowed, dir); - } + let readable = seatbelt_readable_paths(spec); + let writable = seatbelt_writable_paths(spec); let mut profile = String::new(); profile.push_str("(version 1)\n"); @@ -3402,23 +3530,88 @@ fn render_seatbelt_profile(spec: &ProcessSpec) -> String { let mut variants = BTreeSet::new(); insert_seatbelt_path_variants(&mut variants, path); for rendered in variants { - profile.push_str("(deny file-read* (subpath \""); - profile.push_str(&rendered.replace('\\', "\\\\").replace('\"', "\\\"")); - profile.push_str("\"))\n"); - profile.push_str("(deny file-write* (subpath \""); - profile.push_str(&rendered.replace('\\', "\\\\").replace('\"', "\\\"")); - profile.push_str("\"))\n"); + push_seatbelt_path_rule(&mut profile, "deny file-read*", rendered.as_str()); + push_seatbelt_path_rule(&mut profile, "deny file-write*", rendered.as_str()); } } - profile.push_str("(allow file-read*)\n"); - profile.push_str("(allow file-write*"); - for path in allowed { - profile.push_str(" (subpath \""); - profile.push_str(&path.replace('\\', "\\\\").replace('\"', "\\\"")); - profile.push_str("\")"); + push_seatbelt_allow_rule(&mut profile, "file-read*", &readable); + push_seatbelt_allow_rule(&mut profile, "file-write*", &writable); + profile +} + +#[cfg(target_os = "macos")] +fn seatbelt_readable_paths(spec: &ProcessSpec) -> BTreeSet { + let mut out = BTreeSet::new(); + for path in MACOS_DEFAULT_READ_ONLY_PATHS { + insert_seatbelt_path_variants(&mut out, Path::new(path)); + } + if let Some(path) = program_support_path(spec.program.as_str()) { + insert_seatbelt_path_variants(&mut out, &path); + } + for path in seatbelt_writable_pathbufs(spec) { + insert_seatbelt_path_variants(&mut out, &path); + } + for mount in &spec.read_only_mounts { + insert_seatbelt_path_variants(&mut out, &mount.dest); + } + out +} + +#[cfg(target_os = "macos")] +fn seatbelt_writable_paths(spec: &ProcessSpec) -> BTreeSet { + let mut out = BTreeSet::new(); + for path in seatbelt_writable_pathbufs(spec) { + insert_seatbelt_path_variants(&mut out, &path); + } + out +} + +#[cfg(target_os = "macos")] +fn seatbelt_writable_pathbufs(spec: &ProcessSpec) -> BTreeSet { + let mut out = BTreeSet::from([ + spec.work_dir.clone(), + PathBuf::from("/tmp"), + PathBuf::from("/private/tmp"), + ]); + for dir in &spec.writable_dirs { + out.insert(dir.clone()); + } + for dir in &spec.bind_dirs { + out.insert(dir.clone()); + } + for mount in &spec.bind_mounts { + out.insert(mount.dest.clone()); + } + out +} + +#[cfg(target_os = "macos")] +fn push_seatbelt_allow_rule(profile: &mut String, operation: &str, paths: &BTreeSet) { + profile.push_str("(allow "); + profile.push_str(operation); + for path in paths { + push_seatbelt_path_filters(profile, path.as_str()); } profile.push_str(")\n"); - profile +} + +#[cfg(target_os = "macos")] +fn push_seatbelt_path_rule(profile: &mut String, operation: &str, path: &str) { + profile.push('('); + profile.push_str(operation); + push_seatbelt_path_filters(profile, path); + profile.push_str(")\n"); +} + +#[cfg(target_os = "macos")] +fn push_seatbelt_path_filters(profile: &mut String, path: &str) { + let escaped = path.replace('\\', "\\\\").replace('\"', "\\\""); + profile.push_str(" (literal \""); + profile.push_str(&escaped); + profile.push_str("\")"); + profile.push_str(" (subpath \""); + profile.push_str(&escaped); + profile.push_str("\")"); } #[cfg(target_os = "macos")] @@ -3455,8 +3648,8 @@ fn seatbelt_private_alias(path: &str) -> Option { fn missing_direct_sandbox_help() -> &'static str { #[cfg(target_os = "linux")] { - "install bubblewrap (`bwrap`) and ensure it is available in PATH (direct mode also uses \ - `slirp4netns`)" + "install bubblewrap (`bwrap`) and `slirp4netns`, ensure both are available in PATH, and \ + run on a Landlock-enabled Linux kernel" } #[cfg(target_os = "macos")] { @@ -4289,7 +4482,7 @@ mod tests { } #[test] - fn component_program_read_only_mounts_resolve_parent_escape_paths() { + fn component_program_read_only_mounts_include_explicit_paths_and_program_support() { let component = DirectComponentPlan { id: 3, moniker: "app".to_string(), @@ -4305,6 +4498,10 @@ mod tests { program: amber_compiler::reporter::direct::DirectProgramPlan { log_name: "app-program".to_string(), work_dir: "work/components/app".to_string(), + read_only_paths: vec![ + "/workspace/scenarios/app".to_string(), + "/workspace/shared-config".to_string(), + ], storage_mounts: Vec::new(), execution: DirectProgramExecutionPlan::Direct { entrypoint: vec!["/workspace/scenarios/app/../bin/tool".to_string()], @@ -4313,17 +4510,18 @@ mod tests { }, }; - let mounts = component_program_read_only_mounts( - &component, - Some(Path::new("/workspace/scenarios/app")), - ) - .expect("mounts should resolve"); + let mounts = component_program_read_only_mounts(&component).expect("mounts should resolve"); assert!( mounts .iter() .any(|mount| mount.source == Path::new("/workspace/scenarios/app")) ); + assert!( + mounts + .iter() + .any(|mount| mount.source == Path::new("/workspace/shared-config")) + ); assert!( mounts .iter() @@ -4331,6 +4529,52 @@ mod tests { ); } + #[test] + fn component_program_read_only_mounts_keep_helper_runner_entrypoint_support_path() { + let component = DirectComponentPlan { + id: 3, + moniker: "app".to_string(), + log_name: "app".to_string(), + source_dir: Some("/workspace/scenarios/app".to_string()), + depends_on: Vec::new(), + sidecar: amber_compiler::reporter::direct::DirectSidecarPlan { + log_name: "app-sidecar".to_string(), + mesh_port: 0, + mesh_config_path: "mesh/components/app/mesh-config.json".to_string(), + mesh_identity_path: "mesh/components/app/mesh-identity.json".to_string(), + }, + program: amber_compiler::reporter::direct::DirectProgramPlan { + log_name: "app-program".to_string(), + work_dir: "work/components/app".to_string(), + read_only_paths: vec!["/workspace/shared-config".to_string()], + storage_mounts: Vec::new(), + execution: DirectProgramExecutionPlan::HelperRunner { + entrypoint_b64: Some(encode_json_b64(&serde_json::json!([ + "/workspace/tools/app-runner", + "--serve" + ]))), + env_b64: None, + template_spec_b64: None, + runtime_config: None, + mount_spec_b64: None, + }, + }, + }; + + let mounts = component_program_read_only_mounts(&component).expect("mounts should resolve"); + + assert!( + mounts + .iter() + .any(|mount| mount.source == Path::new("/workspace/shared-config")) + ); + assert!( + mounts + .iter() + .any(|mount| mount.source == Path::new("/workspace/tools")) + ); + } + #[test] fn build_runtime_template_context_uses_runtime_slot_ports() { let runtime_addresses = DirectRuntimeAddressPlan { @@ -4546,6 +4790,7 @@ mod tests { program: amber_compiler::reporter::direct::DirectProgramPlan { log_name: "app-program".to_string(), work_dir: "work/components/app".to_string(), + read_only_paths: Vec::new(), storage_mounts: Vec::new(), execution: DirectProgramExecutionPlan::Direct { entrypoint: vec!["/bin/echo".to_string()], @@ -4867,6 +5112,56 @@ mod tests { } } + #[cfg(target_os = "macos")] + fn macos_test_process_spec() -> ProcessSpec { + ProcessSpec { + name: "component".to_string(), + program: "/tmp/amber/bin/server".to_string(), + args: vec!["ok".to_string()], + env: BTreeMap::new(), + work_dir: PathBuf::from("/tmp/amber/work"), + drop_all_caps: false, + read_only_mounts: vec![ReadOnlyMount { + source: PathBuf::from("/tmp/amber/shared"), + dest: PathBuf::from("/tmp/amber/shared"), + }], + writable_dirs: vec![PathBuf::from("/tmp/amber/work")], + bind_dirs: Vec::new(), + bind_mounts: Vec::new(), + hidden_paths: vec![PathBuf::from("/tmp/amber/mesh")], + network: ProcessNetwork::Host, + } + } + + #[cfg(target_os = "macos")] + #[test] + fn seatbelt_profile_limits_reads_to_runtime_defaults_and_allowed_paths() { + let profile = render_seatbelt_profile(&macos_test_process_spec()); + + assert!( + !profile.contains("(allow file-read*)\n"), + "seatbelt profile should not allow global reads: {profile}" + ); + assert!( + profile.contains("(subpath \"/tmp/amber/bin\")"), + "seatbelt profile should allow the program support path: {profile}" + ); + assert!( + profile.contains("(subpath \"/tmp/amber/shared\")"), + "seatbelt profile should allow declared read-only paths: {profile}" + ); + assert!( + profile.contains("(subpath \"/tmp/amber/work\")"), + "seatbelt profile should allow writable paths for reads and writes: {profile}" + ); + assert!( + profile.contains( + "(deny file-read* (literal \"/tmp/amber/mesh\") (subpath \"/tmp/amber/mesh\"))" + ), + "seatbelt profile should deny hidden paths explicitly: {profile}" + ); + } + #[cfg(target_os = "linux")] #[test] fn rewrite_mesh_listen_for_slirp_guest_rewrites_loopback_only() { diff --git a/cli/tests/compile_outputs.rs b/cli/tests/compile_outputs.rs index 3856c1d1..d1eaded7 100644 --- a/cli/tests/compile_outputs.rs +++ b/cli/tests/compile_outputs.rs @@ -4879,6 +4879,163 @@ fn compile_direct_resolves_relative_program_path_into_direct_plan() { .and_then(Value::as_str), Some(expected_program_path.as_str()) ); + assert_eq!( + component + .get("program") + .and_then(|program| program.get("read_only_paths")) + .and_then(Value::as_array), + Some(&vec![Value::String(expected_source_dir)]) + ); +} + +#[test] +fn compile_direct_program_reads_overrides_legacy_source_tree_mounts() { + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("cli crate should live under the workspace root"); + + let outputs_root = workspace_root.join("target").join("cli-test-outputs"); + fs::create_dir_all(&outputs_root).expect("failed to create outputs directory"); + let outputs_dir = tempfile::Builder::new() + .prefix("direct-program-reads-") + .tempdir_in(&outputs_root) + .expect("failed to create outputs directory"); + + let manifest = outputs_dir.path().join("scenario.json5"); + fs::write( + &manifest, + r#"{ + manifest_version: "0.1.0", + program: { + path: "./bin/server", + reads: ["./templates", "/srv/shared", "./templates"], + network: { + endpoints: [ + { name: "http", port: 8080, protocol: "http" } + ] + } + }, + provides: { + http: { kind: "http", endpoint: "http" } + }, + exports: { + http: "http" + } +} +"#, + ) + .expect("failed to write manifest"); + + let artifact_dir = outputs_dir.path().join("direct"); + let output = Command::new(env!("CARGO_BIN_EXE_amber")) + .arg("compile") + .arg("--direct") + .arg(&artifact_dir) + .arg(&manifest) + .output() + .unwrap_or_else(|err| panic!("failed to run amber compile --direct: {err}")); + + assert!( + output.status.success(), + "amber compile --direct failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let direct_plan = fs::read_to_string(artifact_dir.join("direct-plan.json")) + .expect("failed to read direct plan"); + let direct_json: Value = + serde_json::from_str(&direct_plan).expect("direct plan should be valid JSON"); + let component = direct_json["components"][0] + .as_object() + .expect("component should exist"); + let expected_templates = manifest + .parent() + .expect("manifest should have a parent") + .join("./templates") + .display() + .to_string(); + + let expected_read_only_paths = vec![ + Value::String(expected_templates), + Value::String("/srv/shared".to_string()), + ]; + assert_eq!( + component + .get("program") + .and_then(|program| program.get("read_only_paths")) + .and_then(Value::as_array), + Some(&expected_read_only_paths) + ); +} + +#[test] +fn compile_direct_program_reads_empty_disables_legacy_source_tree_mounts() { + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("cli crate should live under the workspace root"); + + let outputs_root = workspace_root.join("target").join("cli-test-outputs"); + fs::create_dir_all(&outputs_root).expect("failed to create outputs directory"); + let outputs_dir = tempfile::Builder::new() + .prefix("direct-program-reads-empty-") + .tempdir_in(&outputs_root) + .expect("failed to create outputs directory"); + + let manifest = outputs_dir.path().join("scenario.json5"); + fs::write( + &manifest, + r#"{ + manifest_version: "0.1.0", + program: { + path: "./bin/server", + reads: [], + network: { + endpoints: [ + { name: "http", port: 8080, protocol: "http" } + ] + } + }, + provides: { + http: { kind: "http", endpoint: "http" } + }, + exports: { + http: "http" + } +} +"#, + ) + .expect("failed to write manifest"); + + let artifact_dir = outputs_dir.path().join("direct"); + let output = Command::new(env!("CARGO_BIN_EXE_amber")) + .arg("compile") + .arg("--direct") + .arg(&artifact_dir) + .arg(&manifest) + .output() + .unwrap_or_else(|err| panic!("failed to run amber compile --direct: {err}")); + + assert!( + output.status.success(), + "amber compile --direct failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let direct_plan = fs::read_to_string(artifact_dir.join("direct-plan.json")) + .expect("failed to read direct plan"); + let direct_json: Value = + serde_json::from_str(&direct_plan).expect("direct plan should be valid JSON"); + + assert_eq!( + direct_json["components"][0]["program"]["read_only_paths"] + .as_array() + .expect("read_only_paths should exist"), + &Vec::::new() + ); } #[test] diff --git a/cli/tests/direct_smoke.rs b/cli/tests/direct_smoke.rs index 2087f64c..835bcd09 100644 --- a/cli/tests/direct_smoke.rs +++ b/cli/tests/direct_smoke.rs @@ -373,6 +373,23 @@ mod linux_direct_smoke { .expect("failed to start amber proxy") } + fn run_amber_to_completion( + direct_out: &Path, + runtime_bin_dir: &Path, + extra_env: &[(&str, &str)], + ) -> std::process::Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_amber")); + cmd.arg("run") + .arg(direct_out) + .env("AMBER_RUNTIME_BIN_DIR", runtime_bin_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in extra_env { + cmd.env(key, value); + } + cmd.output().expect("failed to run amber run") + } + fn drain_pipes(child: &mut std::process::Child) -> (String, String) { let mut stdout = String::new(); if let Some(mut pipe) = child.stdout.take() { @@ -986,4 +1003,223 @@ exec python3 -m http.server 8080 --bind 127.0.0.1 -d /tmp/www shutdown_direct_runtime(&mut child, &mut proxy); } + + #[test] + #[ignore = "requires local runtime binaries and spawns direct processes"] + fn direct_smoke_preserves_legacy_source_tree_reads_when_reads_is_omitted() { + let workspace_root = workspace_root(); + + let outputs_root = workspace_root.join("target").join("cli-test-outputs"); + fs::create_dir_all(&outputs_root).expect("failed to create outputs root"); + let temp = tempfile::Builder::new() + .prefix("direct-source-tree-fs-smoke-") + .tempdir_in(&outputs_root) + .expect("failed to create temp test dir"); + let sibling_secret = temp.path().join("secret.txt"); + fs::write(&sibling_secret, "top-secret").expect("failed to write sibling secret"); + + let port = pick_free_port(); + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("failed to create bin directory"); + let script = bin_dir.join("serve-source-tree.sh"); + fs::write( + &script, + format!( + "#!/bin/sh\nset -eu\ncat '{}' >/dev/null\nexec python3 -m http.server {port} \ + --bind 127.0.0.1\n", + sibling_secret.display() + ), + ) + .expect("failed to write script"); + use std::os::unix::fs::PermissionsExt as _; + let mut perms = fs::metadata(&script) + .expect("script metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod script"); + + let manifest_path = temp.path().join("scenario.json5"); + fs::write( + &manifest_path, + format!( + r#"{{ + manifest_version: "0.1.0", + program: {{ + path: "./bin/serve-source-tree.sh", + network: {{ + endpoints: [ + {{ name: "http", port: {port}, protocol: "http" }}, + ], + }}, + }}, + provides: {{ + http: {{ kind: "http", endpoint: "http" }}, + }}, + exports: {{ + http: "http", + }}, +}} +"# + ), + ) + .expect("failed to write manifest"); + + let direct_out = temp.path().join("out"); + compile_direct_or_panic(&direct_out, &manifest_path); + + let runtime_bin_dir = ensure_runtime_binaries_built(&workspace_root); + let mut child = spawn_amber_run(&direct_out, runtime_bin_dir.as_path(), &[]); + let export_port = pick_free_port(); + let mut proxy = spawn_amber_proxy(&direct_out, "http", export_port); + assert_http_reachable_or_dump( + &mut child, + &mut proxy, + export_port, + "legacy source-tree direct server", + ); + + shutdown_direct_runtime(&mut child, &mut proxy); + } + + #[test] + #[ignore = "requires local runtime binaries and spawns direct processes"] + fn direct_smoke_reads_empty_blocks_ambient_source_tree_reads() { + let workspace_root = workspace_root(); + + let outputs_root = workspace_root.join("target").join("cli-test-outputs"); + fs::create_dir_all(&outputs_root).expect("failed to create outputs root"); + let temp = tempfile::Builder::new() + .prefix("direct-source-tree-reads-empty-smoke-") + .tempdir_in(&outputs_root) + .expect("failed to create temp test dir"); + let sibling_secret = temp.path().join("secret.txt"); + fs::write(&sibling_secret, "top-secret").expect("failed to write sibling secret"); + + let port = pick_free_port(); + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("failed to create bin directory"); + let script = bin_dir.join("check-source-tree.sh"); + fs::write( + &script, + format!( + "#!/bin/sh\nset -eu\nif cat '{}' >/dev/null 2>&1; then\n echo \"ambient \ + source-tree file was readable inside sandbox\" >&2\n exit 42\nfi\nexec python3 \ + -m http.server {port} --bind 127.0.0.1\n", + sibling_secret.display() + ), + ) + .expect("failed to write script"); + use std::os::unix::fs::PermissionsExt as _; + let mut perms = fs::metadata(&script) + .expect("script metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod script"); + + let manifest_path = temp.path().join("scenario.json5"); + fs::write( + &manifest_path, + format!( + r#"{{ + manifest_version: "0.1.0", + program: {{ + path: "./bin/check-source-tree.sh", + reads: [], + network: {{ + endpoints: [ + {{ name: "http", port: {port}, protocol: "http" }}, + ], + }}, + }}, + provides: {{ + http: {{ kind: "http", endpoint: "http" }}, + }}, + exports: {{ + http: "http", + }}, +}} +"# + ), + ) + .expect("failed to write manifest"); + + let direct_out = temp.path().join("out"); + compile_direct_or_panic(&direct_out, &manifest_path); + + let runtime_bin_dir = ensure_runtime_binaries_built(&workspace_root); + let mut child = spawn_amber_run(&direct_out, runtime_bin_dir.as_path(), &[]); + let export_port = pick_free_port(); + let mut proxy = spawn_amber_proxy(&direct_out, "http", export_port); + assert_http_reachable_or_dump( + &mut child, + &mut proxy, + export_port, + "reads-empty direct server", + ); + + shutdown_direct_runtime(&mut child, &mut proxy); + } + + #[test] + #[ignore = "requires local runtime binaries and spawns direct processes"] + fn direct_smoke_seccomp_blocks_ptrace() { + let workspace_root = workspace_root(); + + let outputs_root = workspace_root.join("target").join("cli-test-outputs"); + fs::create_dir_all(&outputs_root).expect("failed to create outputs root"); + let temp = tempfile::Builder::new() + .prefix("direct-seccomp-smoke-") + .tempdir_in(&outputs_root) + .expect("failed to create temp test dir"); + let python = serde_json::to_string( + r#"import ctypes, errno, os, sys +libc = ctypes.CDLL(None, use_errno=True) +PTRACE_TRACEME = 0 +rc = libc.ptrace(PTRACE_TRACEME, 0, 0, 0) +if rc == -1: + err = ctypes.get_errno() + if err == errno.EPERM: + print("ptrace blocked") + sys.exit(0) + raise OSError(err, os.strerror(err)) +print("ptrace unexpectedly succeeded") +sys.exit(1) +"#, + ) + .expect("python should encode"); + let manifest_path = temp.path().join("scenario.json5"); + fs::write( + &manifest_path, + format!( + r#"{{ + manifest_version: "0.1.0", + program: {{ + path: "/usr/bin/env", + args: ["python3", "-c", {python}], + }}, +}} +"# + ), + ) + .expect("failed to write manifest"); + + let direct_out = temp.path().join("out"); + compile_direct_or_panic(&direct_out, &manifest_path); + + let runtime_bin_dir = ensure_runtime_binaries_built(&workspace_root); + let run = run_amber_to_completion(&direct_out, runtime_bin_dir.as_path(), &[]); + assert!( + run.status.success(), + "amber run failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + stdout.contains("ptrace blocked"), + "expected seccomp denial marker in stdout, got:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&run.stderr) + ); + } } diff --git a/compiler/manifest/README.md b/compiler/manifest/README.md index 244b2095..d3553163 100644 --- a/compiler/manifest/README.md +++ b/compiler/manifest/README.md @@ -262,6 +262,15 @@ program: { // }, LOG_LEVEL: "debug", }, + + // reads: optional. + // Direct mode infers the same local source-tree reads it supported before when this field is + // omitted. If it is present, Amber replaces that legacy source-tree read access with these + // manifest-relative or absolute read-only paths instead. Amber still keeps the executable + // support path and platform runtime defaults readable so the process can start. + // + // reads: [".", "../shared-config"], + network: { endpoints: [ { name: "http", port: 8080, protocol: "http" }, @@ -322,6 +331,7 @@ Rules: * `program` must declare exactly one of `image`, `path`, or `vm`. * `program.entrypoint` is only valid with `program.image`. * `program.args` is only valid with `program.path`. +* `program.reads` is only valid with `program.path`. * `program.env` is only valid with `program.image` or `program.path`. VM guest startup should be configured through `program.vm.cloud_init`. * `program.network` and `program.mounts` are only valid with `program.image` or `program.path`. VM programs use `program.vm.network` and `program.vm.mounts`. * `program.path` must be an explicit absolute path or a relative path containing a separator diff --git a/compiler/manifest/src/manifest/tests.rs b/compiler/manifest/src/manifest/tests.rs index 8a4a2020..fac353ee 100644 --- a/compiler/manifest/src/manifest/tests.rs +++ b/compiler/manifest/src/manifest/tests.rs @@ -452,6 +452,29 @@ fn program_path_args_string_sugar_splits() { assert_eq!(program.args.0[3].arg().unwrap().to_string(), "8080"); } +#[test] +fn program_path_accepts_reads() { + let manifest: Manifest = r#" + { + manifest_version: "0.1.0", + program: { + path: "./bin/server", + reads: [".", "../shared-config"], + } + } + "# + .parse() + .unwrap(); + + let Program::Path(program) = manifest.program.as_ref().expect("program should exist") else { + panic!("expected native path program"); + }; + assert_eq!( + program.reads, + Some(vec![".".to_string(), "../shared-config".to_string()]) + ); +} + #[test] fn program_requires_exactly_one_source_field() { let both = r#" @@ -545,6 +568,49 @@ fn program_path_rejects_null_entrypoint_field() { ); } +#[test] +fn program_image_rejects_reads_field() { + let err = r#" + { + manifest_version: "0.1.0", + program: { + image: "example:latest", + entrypoint: ["/bin/true"], + reads: ["."], + } + } + "# + .parse::() + .unwrap_err(); + assert!( + err.to_string() + .contains("program.reads is only supported with program.path") + ); +} + +#[test] +fn program_vm_rejects_reads_field() { + let err = r#" + { + manifest_version: "0.1.0", + program: { + vm: { + image: "ghcr.io/acme/base:latest", + cpus: 1, + memory_mib: 512, + }, + reads: ["."], + } + } + "# + .parse::() + .unwrap_err(); + assert!( + err.to_string() + .contains("program.reads is only supported with program.path") + ); +} + #[test] fn program_image_rejects_empty_args_field() { let err = r#" diff --git a/compiler/manifest/src/schema.rs b/compiler/manifest/src/schema.rs index b4662c42..b33b22e5 100644 --- a/compiler/manifest/src/schema.rs +++ b/compiler/manifest/src/schema.rs @@ -295,6 +295,8 @@ impl<'de> Deserialize<'de> for Program { #[serde(default)] #[serde(deserialize_with = "double_option::deserialize")] args: Option>, + #[serde(default)] + reads: Option>, #[serde_as(as = "MapPreventDuplicates<_, _>")] #[serde(default)] env: BTreeMap, @@ -312,6 +314,11 @@ impl<'de> Deserialize<'de> for Program { "program.args is only supported with program.path", )); } + if fields.reads.is_some() { + return Err(serde::de::Error::custom( + "program.reads is only supported with program.path", + )); + } Ok(Self::Image(ProgramImage { image: image.0, entrypoint: fields.entrypoint.flatten().unwrap_or_default(), @@ -331,6 +338,7 @@ impl<'de> Deserialize<'de> for Program { Ok(Self::Path(ProgramPath { path: path.0, args: fields.args.flatten().unwrap_or_default(), + reads: fields.reads, common: ProgramCommon { env: fields.env, network: fields.network, @@ -349,6 +357,11 @@ impl<'de> Deserialize<'de> for Program { "program.args is only supported with program.path", )); } + if fields.reads.is_some() { + return Err(serde::de::Error::custom( + "program.reads is only supported with program.path", + )); + } if !fields.env.is_empty() { return Err(serde::de::Error::custom( "program.env is not supported with program.vm; configure guest startup \ @@ -895,6 +908,8 @@ impl<'de> Deserialize<'de> for RawProgram { #[serde(default)] #[serde(deserialize_with = "double_option::deserialize")] args: Option>, + #[serde(default)] + reads: Option>, #[serde_as(as = "MapPreventDuplicates<_, _>")] #[serde(default)] env: BTreeMap, @@ -912,6 +927,11 @@ impl<'de> Deserialize<'de> for RawProgram { "program.args is only supported with program.path", )); } + if fields.reads.is_some() { + return Err(serde::de::Error::custom( + "program.reads is only supported with program.path", + )); + } Ok(Self::Image(RawProgramImage { image, entrypoint: fields.entrypoint.flatten().unwrap_or_default(), @@ -931,6 +951,7 @@ impl<'de> Deserialize<'de> for RawProgram { Ok(Self::Path(RawProgramPath { path, args: fields.args.flatten().unwrap_or_default(), + reads: fields.reads, common: RawProgramCommon { env: fields.env, network: fields.network, @@ -949,6 +970,11 @@ impl<'de> Deserialize<'de> for RawProgram { "program.args is only supported with program.path", )); } + if fields.reads.is_some() { + return Err(serde::de::Error::custom( + "program.reads is only supported with program.path", + )); + } if !fields.env.is_empty() { return Err(serde::de::Error::custom( "program.env is not supported with program.vm; configure guest startup \ @@ -1058,6 +1084,8 @@ pub struct RawProgramPath { #[serde(default)] #[builder(default)] pub args: RawProgramEntrypoint, + #[serde(default)] + pub reads: Option>, #[serde(flatten)] pub common: RawProgramCommon, } @@ -1073,6 +1101,7 @@ impl RawProgramPath { args: self.args.resolve("/program/args", &mut |value, pointer| { resolver.resolve_inline_string(value, pointer) })?, + reads: self.reads, common: self.common.resolve("/program", resolver)?, }) } @@ -1244,6 +1273,7 @@ impl From for RawProgramPath { Self { path: value.path.into(), args: value.args.into(), + reads: value.reads, common: value.common.into(), } } @@ -1336,6 +1366,8 @@ pub struct ProgramPath { #[serde(default)] #[builder(default)] pub args: ProgramEntrypoint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reads: Option>, #[serde(flatten)] pub common: ProgramCommon, } diff --git a/compiler/scenario/src/program.rs b/compiler/scenario/src/program.rs index c6fba212..36e971e4 100644 --- a/compiler/scenario/src/program.rs +++ b/compiler/scenario/src/program.rs @@ -154,6 +154,8 @@ impl Serialize for Program { entrypoint: Option<&'a ProgramEntrypoint>, #[serde(skip_serializing_if = "Option::is_none")] args: Option<&'a ProgramEntrypoint>, + #[serde(skip_serializing_if = "Option::is_none")] + reads: Option<&'a Vec>, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] env: &'a BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] @@ -169,6 +171,7 @@ impl Serialize for Program { vm: None, entrypoint: Some(&program.entrypoint), args: None, + reads: None, env: &program.common.env, network: program.common.network.as_ref(), mounts: &program.common.mounts, @@ -179,6 +182,7 @@ impl Serialize for Program { vm: None, entrypoint: None, args: Some(&program.args), + reads: program.reads.as_ref(), env: &program.common.env, network: program.common.network.as_ref(), mounts: &program.common.mounts, @@ -189,6 +193,7 @@ impl Serialize for Program { vm: Some(program), entrypoint: None, args: None, + reads: None, env: &EMPTY_PROGRAM_ENV, network: None, mounts: &[], @@ -218,6 +223,8 @@ impl<'de> Deserialize<'de> for Program { #[serde(default)] args: Option, #[serde(default)] + reads: Option>, + #[serde(default)] env: BTreeMap, #[serde(default)] network: Option, @@ -233,6 +240,11 @@ impl<'de> Deserialize<'de> for Program { "program.args is only supported with program.path", )); } + if fields.reads.is_some() { + return Err(serde::de::Error::custom( + "program.reads is only supported with program.path", + )); + } Ok(Self::Image(ProgramImage { image: image.0, entrypoint: fields.entrypoint.unwrap_or_default(), @@ -252,6 +264,7 @@ impl<'de> Deserialize<'de> for Program { Ok(Self::Path(ProgramPath { path: path.0, args: fields.args.unwrap_or_default(), + reads: fields.reads, common: ProgramCommon { env: fields.env, network: fields.network, @@ -270,6 +283,11 @@ impl<'de> Deserialize<'de> for Program { "program.args is only supported with program.path", )); } + if fields.reads.is_some() { + return Err(serde::de::Error::custom( + "program.reads is only supported with program.path", + )); + } if !fields.env.is_empty() { return Err(serde::de::Error::custom( "program.env is not supported with program.vm", @@ -328,6 +346,8 @@ pub struct ProgramPath { pub path: String, #[serde(default)] pub args: ProgramEntrypoint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reads: Option>, #[serde(flatten)] pub common: ProgramCommon, } diff --git a/compiler/src/linker/program_lowering.rs b/compiler/src/linker/program_lowering.rs index 386ffc98..f3facb50 100644 --- a/compiler/src/linker/program_lowering.rs +++ b/compiler/src/linker/program_lowering.rs @@ -139,6 +139,7 @@ pub(crate) fn lower_program_with_config_analysis( program: Program::Path(ProgramPath { path: program.path.clone(), args: program.args.clone(), + reads: program.reads.clone(), common: common.common, }), mount_source_indices: common.mount_source_indices, diff --git a/compiler/src/targets/direct/mod.rs b/compiler/src/targets/direct/mod.rs index ade1c959..0a70e6d1 100644 --- a/compiler/src/targets/direct/mod.rs +++ b/compiler/src/targets/direct/mod.rs @@ -44,7 +44,7 @@ use crate::{ }, }; -pub const DIRECT_PLAN_VERSION: &str = "3"; +pub const DIRECT_PLAN_VERSION: &str = "4"; pub const DIRECT_PLAN_FILENAME: &str = "direct-plan.json"; pub const RUN_SCRIPT_FILENAME: &str = "run.sh"; pub const MESH_PROVISION_PLAN_FILENAME: &str = "mesh-provision-plan.json"; @@ -128,6 +128,8 @@ pub struct DirectSidecarPlan { pub struct DirectProgramPlan { pub log_name: String, pub work_dir: String, + #[serde(default)] + pub read_only_paths: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub storage_mounts: Vec, pub execution: DirectProgramExecutionPlan, @@ -445,6 +447,8 @@ fn build_component_plans( source_dir.as_deref(), component.moniker.as_str(), )?; + let read_only_paths = + direct_program_read_only_paths(component.program.as_ref(), source_dir.as_deref())?; out.push(DirectComponentPlan { id: id.0, @@ -463,6 +467,7 @@ fn build_component_plans( program: DirectProgramPlan { log_name: format!("{}-program", names.base), work_dir: names.work_dir.clone(), + read_only_paths, storage_mounts: direct_storage_mounts( storage_plan.mounts_by_component.get(id).map(Vec::as_slice), ), @@ -486,6 +491,51 @@ fn direct_storage_mounts( out } +fn direct_program_read_only_paths( + program: Option<&amber_scenario::Program>, + source_dir: Option<&Path>, +) -> Result, MeshError> { + let Some(amber_scenario::Program::Path(program)) = program else { + return Ok(Vec::new()); + }; + + let Some(reads) = program.reads.as_ref() else { + return Ok(source_dir + .into_iter() + .map(|path| path.display().to_string()) + .collect()); + }; + + let mut paths = BTreeSet::new(); + for read in reads { + paths.insert(resolve_direct_read_path(read, source_dir)?); + } + Ok(paths.into_iter().collect()) +} + +fn resolve_direct_read_path(path: &str, source_dir: Option<&Path>) -> Result { + let read_path = Path::new(path); + if read_path.is_absolute() { + return Ok(path.to_string()); + } + + let source_dir = source_dir.ok_or_else(|| { + MeshError::new(format!( + "direct program read path `{path}` is relative, but Amber can only resolve relative \ + reads for components compiled from local file manifests" + )) + })?; + if !source_dir.is_absolute() { + return Err(MeshError::new(format!( + "component source directory {} is not absolute; cannot resolve direct read path \ + `{path}`", + source_dir.display() + ))); + } + + Ok(source_dir.join(read_path).display().to_string()) +} + fn direct_storage_state_subdir(identity: &StorageIdentity) -> String { format!( "{}/{}-{}", @@ -1026,8 +1076,10 @@ mod tests { sync::Arc, }; - use amber_manifest::Manifest; - use amber_scenario::{BindingEdge, Component, Moniker, Scenario}; + use amber_manifest::{Manifest, ProgramEntrypoint}; + use amber_scenario::{ + BindingEdge, Component, Moniker, Program, ProgramCommon, ProgramPath, Scenario, + }; use super::*; use crate::{ @@ -1126,6 +1178,44 @@ mod tests { ); } + #[test] + fn direct_program_read_only_paths_preserve_legacy_source_dir_when_reads_are_omitted() { + let paths = direct_program_read_only_paths( + Some(&Program::Path(ProgramPath { + path: "./bin/server".to_string(), + args: ProgramEntrypoint::default(), + reads: None, + common: ProgramCommon::default(), + })), + Some(Path::new("/workspace/app")), + ) + .expect("legacy read-only paths should resolve"); + + assert_eq!(paths, vec!["/workspace/app".to_string()]); + } + + #[test] + fn direct_program_read_only_paths_use_explicit_reads_exactly() { + let paths = direct_program_read_only_paths( + Some(&Program::Path(ProgramPath { + path: "./bin/server".to_string(), + args: ProgramEntrypoint::default(), + reads: Some(vec!["./templates".to_string(), "/srv/shared".to_string()]), + common: ProgramCommon::default(), + })), + Some(Path::new("/workspace/app")), + ) + .expect("explicit read-only paths should resolve"); + + assert_eq!( + paths, + vec![ + "/srv/shared".to_string(), + "/workspace/app/./templates".to_string(), + ] + ); + } + #[test] fn resolve_direct_program_path_requires_local_file_provenance_for_relative_paths() { let err = resolve_direct_program_path("./bin/server", None, "app") diff --git a/runtime/helper/Cargo.toml b/runtime/helper/Cargo.toml index c61231c5..3e87fb21 100644 --- a/runtime/helper/Cargo.toml +++ b/runtime/helper/Cargo.toml @@ -9,6 +9,7 @@ base64 = { workspace = true } jsonschema = { workspace = true } amber-template = { workspace = true } libc = "0.2.177" +linux-raw-sys = { version = "0.12.1", features = ["landlock", "ptrace"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } signal-hook = "0.3.18" diff --git a/runtime/helper/src/lib.rs b/runtime/helper/src/lib.rs index f6c9796d..b60eafa9 100644 --- a/runtime/helper/src/lib.rs +++ b/runtime/helper/src/lib.rs @@ -3,7 +3,7 @@ use std::{ ffi::OsString, fs, io::ErrorKind, - path::Path, + path::{Path, PathBuf}, thread, time::{Duration, Instant}, }; @@ -28,6 +28,7 @@ const RESOLVED_ENV_ENV: &str = "AMBER_RESOLVED_ENV_B64"; const MOUNT_SPEC_ENV: &str = "AMBER_MOUNT_SPEC_B64"; const DOCKER_MOUNT_PROXY_SPEC_ENV: &str = "AMBER_DOCKER_MOUNT_PROXY_SPEC_B64"; const RUNTIME_TEMPLATE_CONTEXT_ENV: &str = "AMBER_RUNTIME_TEMPLATE_CONTEXT_B64"; +pub const DIRECT_HARDENING_ENV: &str = "AMBER_DIRECT_HARDENING_B64"; #[derive(Debug, Deserialize, Serialize)] struct DockerMountProxySpec { @@ -67,6 +68,12 @@ pub enum HelperError { pub type Result = std::result::Result; +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DirectHardeningPlan { + pub read_only_paths: Vec, + pub writable_paths: Vec, +} + pub fn wait_for_mesh_config_scope( config_path: &Path, expected_scope: &str, @@ -139,6 +146,7 @@ pub struct RunPlan { pub entrypoint: Vec, pub env: BTreeMap, pub docker_mount_proxies: Vec<(String, String, u16)>, + pub direct_hardening: Option, } pub fn build_run_plan(env: impl IntoIterator) -> Result { @@ -153,6 +161,7 @@ pub fn build_run_plan(env: impl IntoIterator) -> Re let mut mount_spec_b64 = None; let mut docker_mount_proxy_spec_b64 = None; let mut runtime_template_context_b64 = None; + let mut direct_hardening_b64 = None; for (key, value) in env { let Some(key_str) = key.to_str() else { @@ -214,6 +223,12 @@ pub fn build_run_plan(env: impl IntoIterator) -> Re })?; runtime_template_context_b64 = Some(value); } + DIRECT_HARDENING_ENV => { + let value = value.into_string().map_err(|_| { + HelperError::Msg(format!("{DIRECT_HARDENING_ENV} must be valid UTF-8")) + })?; + direct_hardening_b64 = Some(value); + } _ if key_str.starts_with(CONFIG_ENV_PREFIX) => { let value = value .into_string() @@ -242,6 +257,14 @@ pub fn build_run_plan(env: impl IntoIterator) -> Re } else { RuntimeTemplateContext::default() }; + let direct_hardening = if let Some(raw) = direct_hardening_b64.as_deref() { + Some(decode_b64_json_t::( + DIRECT_HARDENING_ENV, + raw, + )?) + } else { + None + }; let config_payload_present = root_schema_b64.is_some() || component_schema_b64.is_some() @@ -411,6 +434,7 @@ pub fn build_run_plan(env: impl IntoIterator) -> Re .into_iter() .map(|spec| (spec.path, spec.tcp_host, spec.tcp_port)) .collect(), + direct_hardening, }) } @@ -1680,6 +1704,40 @@ mod tests { ); } + #[test] + fn helper_decodes_direct_hardening_plan() { + use base64::engine::general_purpose::STANDARD; + + let entrypoint = vec!["/bin/echo".to_string(), "ok".to_string()]; + let env = BTreeMap::::new(); + let hardening = DirectHardeningPlan { + read_only_paths: vec![PathBuf::from("/usr"), PathBuf::from("/app/bin")], + writable_paths: vec![PathBuf::from("/tmp"), PathBuf::from("/app/state")], + }; + + let envs = BTreeMap::from([ + ( + RESOLVED_ENTRYPOINT_ENV.to_string(), + STANDARD.encode(serde_json::to_vec(&entrypoint).unwrap()), + ), + ( + RESOLVED_ENV_ENV.to_string(), + STANDARD.encode(serde_json::to_vec(&env).unwrap()), + ), + ( + DIRECT_HARDENING_ENV.to_string(), + STANDARD.encode(serde_json::to_vec(&hardening).unwrap()), + ), + ]); + + let os_env = envs + .into_iter() + .map(|(k, v)| (OsString::from(k), OsString::from(v))); + let plan = build_run_plan(os_env).expect("build run plan"); + + assert_eq!(plan.direct_hardening, Some(hardening)); + } + #[cfg(unix)] #[test] fn config_env_values_must_be_utf8() { diff --git a/runtime/helper/src/main.rs b/runtime/helper/src/main.rs index 1432d6f7..a2823ff7 100644 --- a/runtime/helper/src/main.rs +++ b/runtime/helper/src/main.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +use std::mem::offset_of; use std::{ env, fs, io::{BufRead as _, BufReader, Read}, @@ -13,13 +15,36 @@ use std::{ os::unix::net::{UnixListener, UnixStream}, os::unix::process::{CommandExt, ExitStatusExt}, }; +#[cfg(target_os = "linux")] +use std::{ + os::fd::{AsRawFd as _, FromRawFd as _}, + os::unix::fs::OpenOptionsExt as _, +}; -use amber_helper::{HelperError, RunPlan, build_run_plan, wait_for_mesh_config_scope}; +use amber_helper::{ + DirectHardeningPlan, HelperError, RunPlan, build_run_plan, wait_for_mesh_config_scope, +}; use amber_mesh::telemetry::{ COMPONENT_MONIKER_ENV, OtlpIdentity, OtlpInstallMode, SCENARIO_SCOPE_ENV, SubscriberFormat, SubscriberOptions, init_otel_tracer, init_subscriber, observability_log_scope_name, shutdown_tracer_provider, structured_logs_enabled, }; +#[cfg(target_os = "linux")] +use linux_raw_sys::landlock::{ + LANDLOCK_ACCESS_FS_EXECUTE, LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_MAKE_BLOCK, + LANDLOCK_ACCESS_FS_MAKE_CHAR, LANDLOCK_ACCESS_FS_MAKE_DIR, LANDLOCK_ACCESS_FS_MAKE_FIFO, + LANDLOCK_ACCESS_FS_MAKE_REG, LANDLOCK_ACCESS_FS_MAKE_SOCK, LANDLOCK_ACCESS_FS_MAKE_SYM, + LANDLOCK_ACCESS_FS_READ_DIR, LANDLOCK_ACCESS_FS_READ_FILE, LANDLOCK_ACCESS_FS_REFER, + LANDLOCK_ACCESS_FS_REMOVE_DIR, LANDLOCK_ACCESS_FS_REMOVE_FILE, LANDLOCK_ACCESS_FS_TRUNCATE, + LANDLOCK_ACCESS_FS_WRITE_FILE, LANDLOCK_CREATE_RULESET_VERSION, landlock_path_beneath_attr, + landlock_rule_type, landlock_ruleset_attr, +}; +#[cfg(all(target_os = "linux", target_arch = "aarch64"))] +use linux_raw_sys::ptrace::AUDIT_ARCH_AARCH64; +#[cfg(all(target_os = "linux", target_arch = "riscv64"))] +use linux_raw_sys::ptrace::AUDIT_ARCH_RISCV64; +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +use linux_raw_sys::ptrace::AUDIT_ARCH_X86_64; #[cfg(unix)] use signal_hook::{consts::signal, iterator::Signals}; use tracing_subscriber::EnvFilter; @@ -182,6 +207,7 @@ fn exec_plan(plan: RunPlan) -> Result { entrypoint, env, docker_mount_proxies, + direct_hardening, } = plan; if !docker_mount_proxies.is_empty() { @@ -206,7 +232,11 @@ fn exec_plan(plan: RunPlan) -> Result { #[cfg(unix)] { - let status = run_child_with_signal_forwarding(&mut cmd, component_moniker.as_str())?; + let status = run_child_with_signal_forwarding( + &mut cmd, + component_moniker.as_str(), + direct_hardening.as_ref(), + )?; Ok(exit_code_from_status(status)) } @@ -217,6 +247,11 @@ fn exec_plan(plan: RunPlan) -> Result { "docker mount proxy injection is only supported on unix targets".to_string(), )); } + if direct_hardening.is_some() { + return Err(HelperError::Msg( + "direct hardening payloads are only supported on unix targets".to_string(), + )); + } let status = run_child_with_log_capture(&mut cmd, component_moniker.as_str())?; Ok(exit_code_from_status(status)) @@ -227,12 +262,20 @@ fn exec_plan(plan: RunPlan) -> Result { fn run_child_with_signal_forwarding( cmd: &mut Command, component_moniker: &str, + direct_hardening: Option<&DirectHardeningPlan>, ) -> Result { + let direct_hardening = direct_hardening.cloned(); + #[cfg(not(target_os = "linux"))] + let _ = &direct_hardening; // Isolate the workload in its own process group so we can relay stop signals to // the full workload tree without signaling amber-helper itself. unsafe { - cmd.pre_exec(|| { + cmd.pre_exec(move || { if libc::setpgid(0, 0) == 0 { + #[cfg(target_os = "linux")] + if let Some(plan) = direct_hardening.as_ref() { + apply_linux_direct_hardening(plan)?; + } Ok(()) } else { Err(io::Error::last_os_error()) @@ -375,6 +418,354 @@ fn exit_code_from_status(status: ExitStatus) -> ExitCode { ExitCode::from(1) } +#[cfg(target_os = "linux")] +fn apply_linux_direct_hardening(plan: &DirectHardeningPlan) -> io::Result<()> { + enable_no_new_privs()?; + apply_landlock(plan) + .map_err(|err| io::Error::other(format!("failed to apply Landlock ruleset: {err}")))?; + apply_seccomp() + .map_err(|err| io::Error::other(format!("failed to install seccomp filter: {err}"))) +} + +#[cfg(target_os = "linux")] +fn enable_no_new_privs() -> io::Result<()> { + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +fn apply_landlock(plan: &DirectHardeningPlan) -> io::Result<()> { + let abi_version = landlock_abi_version()?; + let handled_access_fs = landlock_handled_access_fs(abi_version); + let ruleset_attr = landlock_ruleset_attr { + handled_access_fs, + handled_access_net: 0, + scoped: 0, + }; + let ruleset_fd = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + &ruleset_attr as *const landlock_ruleset_attr, + std::mem::size_of::(), + 0usize, + ) + }; + if ruleset_fd < 0 { + return Err(io::Error::last_os_error()); + } + let ruleset_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(ruleset_fd as i32) }; + + let read_only_access = u64::from( + LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR, + ); + let writable_access = landlock_handled_access_fs(abi_version); + + for path in &plan.read_only_paths { + add_landlock_rule(ruleset_fd.as_raw_fd(), path, read_only_access)?; + } + for path in &plan.writable_paths { + add_landlock_rule(ruleset_fd.as_raw_fd(), path, writable_access)?; + } + + let rc = unsafe { + libc::syscall( + libc::SYS_landlock_restrict_self, + ruleset_fd.as_raw_fd(), + 0u32, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +fn landlock_abi_version() -> io::Result { + let rc = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + std::ptr::null::(), + 0usize, + LANDLOCK_CREATE_RULESET_VERSION, + ) + }; + if rc >= 0 { + u32::try_from(rc).map_err(|_| io::Error::other("landlock ABI version is out of range")) + } else { + let err = io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::ENOSYS) | Some(libc::EOPNOTSUPP) | Some(libc::EINVAL) => { + Err(io::Error::other( + "linux direct mode requires a Landlock-enabled kernel; the current kernel \ + does not support Amber's Landlock rulesets", + )) + } + _ => Err(err), + } + } +} + +#[cfg(target_os = "linux")] +fn landlock_handled_access_fs(abi_version: u32) -> u64 { + let mut access = u64::from( + LANDLOCK_ACCESS_FS_EXECUTE + | LANDLOCK_ACCESS_FS_WRITE_FILE + | LANDLOCK_ACCESS_FS_READ_FILE + | LANDLOCK_ACCESS_FS_READ_DIR + | LANDLOCK_ACCESS_FS_REMOVE_DIR + | LANDLOCK_ACCESS_FS_REMOVE_FILE + | LANDLOCK_ACCESS_FS_MAKE_CHAR + | LANDLOCK_ACCESS_FS_MAKE_DIR + | LANDLOCK_ACCESS_FS_MAKE_REG + | LANDLOCK_ACCESS_FS_MAKE_SOCK + | LANDLOCK_ACCESS_FS_MAKE_FIFO + | LANDLOCK_ACCESS_FS_MAKE_BLOCK + | LANDLOCK_ACCESS_FS_MAKE_SYM, + ); + if abi_version >= 2 { + access |= u64::from(LANDLOCK_ACCESS_FS_REFER); + } + if abi_version >= 3 { + access |= u64::from(LANDLOCK_ACCESS_FS_TRUNCATE); + } + if abi_version >= 5 { + access |= u64::from(LANDLOCK_ACCESS_FS_IOCTL_DEV); + } + access +} + +#[cfg(target_os = "linux")] +fn add_landlock_rule(ruleset_fd: i32, path: &Path, allowed_access: u64) -> io::Result<()> { + let file = match fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open(path) + { + Ok(file) => file, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + let rule = landlock_path_beneath_attr { + allowed_access, + parent_fd: file.as_raw_fd(), + }; + let rc = unsafe { + libc::syscall( + libc::SYS_landlock_add_rule, + ruleset_fd, + landlock_rule_type::LANDLOCK_RULE_PATH_BENEATH as u32, + &rule as *const landlock_path_beneath_attr, + 0u32, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "x86_64" + ) +))] +fn apply_seccomp() -> io::Result<()> { + let mut filter = seccomp_filter_program(); + let mut program = libc::sock_fprog { + len: filter.len() as u16, + filter: filter.as_mut_ptr(), + }; + let rc = unsafe { + libc::prctl( + libc::PR_SET_SECCOMP, + libc::SECCOMP_MODE_FILTER, + &mut program as *mut libc::sock_fprog, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(all( + target_os = "linux", + not(any( + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "x86_64" + )) +))] +fn apply_seccomp() -> io::Result<()> { + Err(io::Error::other(format!( + "linux direct mode seccomp hardening is not implemented for architecture {}", + std::env::consts::ARCH + ))) +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "x86_64" + ) +))] +fn seccomp_filter_program() -> Vec { + let deny = seccomp_errno(libc::EPERM as u16); + unsafe { + let mut filter = vec![ + libc::BPF_STMT( + (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16, + offset_of!(libc::seccomp_data, arch) as u32, + ), + libc::BPF_JUMP( + (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, + native_audit_arch(), + 1, + 0, + ), + libc::BPF_STMT( + (libc::BPF_RET | libc::BPF_K) as u16, + libc::SECCOMP_RET_KILL_PROCESS, + ), + libc::BPF_STMT( + (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16, + offset_of!(libc::seccomp_data, nr) as u32, + ), + ]; + + for syscall in denied_syscalls() { + filter.push(libc::BPF_JUMP( + (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, + *syscall as u32, + 0, + 1, + )); + filter.push(libc::BPF_STMT((libc::BPF_RET | libc::BPF_K) as u16, deny)); + } + + // Amber components only communicate over Unix sockets, declared TCP/HTTP capability + // paths, and optional public-network egress over IP sockets. Other socket families are + // outside Amber's transport model and are denied in direct mode. + filter.extend_from_slice(&[ + libc::BPF_JUMP( + (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, + libc::SYS_socket as u32, + 0, + 5, + ), + libc::BPF_STMT( + (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16, + offset_of!(libc::seccomp_data, args) as u32, + ), + libc::BPF_JUMP( + (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, + libc::AF_UNIX as u32, + 3, + 0, + ), + libc::BPF_JUMP( + (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, + libc::AF_INET as u32, + 2, + 0, + ), + libc::BPF_JUMP( + (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, + libc::AF_INET6 as u32, + 1, + 0, + ), + libc::BPF_STMT((libc::BPF_RET | libc::BPF_K) as u16, deny), + libc::BPF_STMT( + (libc::BPF_RET | libc::BPF_K) as u16, + libc::SECCOMP_RET_ALLOW, + ), + ]); + + filter + } +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "x86_64" + ) +))] +fn seccomp_errno(errno: u16) -> u32 { + libc::SECCOMP_RET_ERRNO | u32::from(errno) +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "x86_64" + ) +))] +fn denied_syscalls() -> &'static [libc::c_long] { + &[ + libc::SYS_ptrace, + libc::SYS_process_vm_readv, + libc::SYS_process_vm_writev, + libc::SYS_kcmp, + libc::SYS_unshare, + libc::SYS_setns, + libc::SYS_mount, + libc::SYS_umount2, + libc::SYS_pivot_root, + libc::SYS_open_by_handle_at, + libc::SYS_bpf, + libc::SYS_perf_event_open, + libc::SYS_fanotify_init, + libc::SYS_open_tree, + libc::SYS_move_mount, + libc::SYS_fsopen, + libc::SYS_fsconfig, + libc::SYS_fsmount, + libc::SYS_fspick, + libc::SYS_mount_setattr, + ] +} + +#[cfg(all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "x86_64" + ) +))] +fn native_audit_arch() -> u32 { + #[cfg(target_arch = "aarch64")] + { + AUDIT_ARCH_AARCH64 + } + #[cfg(target_arch = "riscv64")] + { + AUDIT_ARCH_RISCV64 + } + #[cfg(target_arch = "x86_64")] + { + AUDIT_ARCH_X86_64 + } +} + #[cfg(unix)] fn start_docker_mount_proxies(specs: &[(String, String, u16)]) -> Result<(), HelperError> { for (path, tcp_host, tcp_port) in specs { From 30ec2d1367f72cd7b277ca6ba5238273c8a10e5f Mon Sep 17 00:00:00 2001 From: Nick Hynes Date: Tue, 24 Mar 2026 14:03:45 +0800 Subject: [PATCH 2/2] fix ci --- cli/src/main.rs | 38 ++++++++++++++++++++----------------- docker/amber-cli/Dockerfile | 3 ++- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 179cc78d..ec567924 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1767,9 +1767,6 @@ fn component_program_spec( let bind_mounts = direct_storage_bind_mounts(storage_root, component)?; match &component.program.execution { DirectProgramExecutionPlan::Direct { entrypoint, env } => { - let (program, args) = split_entrypoint(entrypoint)?; - let program = - ensure_absolute_direct_program_path(&program, component.moniker.as_str())?; #[cfg(target_os = "linux")] { let helper_binary = resolve_runtime_binary("amber-helper")?; @@ -1808,20 +1805,26 @@ fn component_program_spec( network: ProcessNetwork::Host, }); } - Ok(ProcessSpec { - name: component.program.log_name.clone(), - program, - args, - env: env.clone(), - work_dir, - drop_all_caps: false, - read_only_mounts, - writable_dirs, - bind_dirs: Vec::new(), - bind_mounts, - hidden_paths: Vec::new(), - network: ProcessNetwork::Host, - }) + #[cfg(not(target_os = "linux"))] + { + let (program, args) = split_entrypoint(entrypoint)?; + let program = + ensure_absolute_direct_program_path(&program, component.moniker.as_str())?; + Ok(ProcessSpec { + name: component.program.log_name.clone(), + program, + args, + env: env.clone(), + work_dir, + drop_all_caps: false, + read_only_mounts, + writable_dirs, + bind_dirs: Vec::new(), + bind_mounts, + hidden_paths: Vec::new(), + network: ProcessNetwork::Host, + }) + } } DirectProgramExecutionPlan::HelperRunner { entrypoint_b64, @@ -2064,6 +2067,7 @@ fn append_runtime_config_env( Ok(()) } +#[cfg(not(target_os = "linux"))] fn split_entrypoint(entrypoint: &[String]) -> Result<(String, Vec)> { let Some(program) = entrypoint.first() else { return Err(miette::miette!("program entrypoint must not be empty")); diff --git a/docker/amber-cli/Dockerfile b/docker/amber-cli/Dockerfile index 6f6f6ff7..c57b95e8 100644 --- a/docker/amber-cli/Dockerfile +++ b/docker/amber-cli/Dockerfile @@ -50,13 +50,14 @@ COPY runtime/provisioner/Cargo.toml runtime/provisioner/ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ mkdir -p cli/src compiler/config/src compiler/src runtime/docker-gateway/src runtime/helper/src images/src manager/src compiler/json5/src compiler/manifest/src runtime/mesh/src runtime/proxy/src runtime/router/src compiler/resolver/src compiler/scenario/src compiler/template/src node/src runtime/provisioner/src && \ - touch cli/src/main.rs compiler/config/src/lib.rs compiler/src/lib.rs runtime/docker-gateway/src/main.rs runtime/helper/src/main.rs images/src/lib.rs manager/src/lib.rs manager/src/main.rs compiler/json5/src/lib.rs compiler/manifest/src/lib.rs runtime/mesh/src/lib.rs runtime/proxy/src/lib.rs runtime/router/src/main.rs compiler/resolver/src/lib.rs compiler/scenario/src/lib.rs compiler/template/src/lib.rs node/src/main.rs runtime/provisioner/src/main.rs && \ + touch cli/src/main.rs compiler/config/src/lib.rs compiler/src/lib.rs runtime/docker-gateway/src/main.rs runtime/helper/src/lib.rs runtime/helper/src/main.rs images/src/lib.rs manager/src/lib.rs manager/src/main.rs compiler/json5/src/lib.rs compiler/manifest/src/lib.rs runtime/mesh/src/lib.rs runtime/proxy/src/lib.rs runtime/router/src/main.rs compiler/resolver/src/lib.rs compiler/scenario/src/lib.rs compiler/template/src/lib.rs node/src/main.rs runtime/provisioner/src/main.rs && \ cargo fetch --locked COPY cli ./cli COPY compiler ./compiler COPY examples ./examples COPY images ./images +COPY runtime/helper ./runtime/helper COPY runtime/mesh ./runtime/mesh COPY runtime/proxy ./runtime/proxy COPY runtime/router ./runtime/router