From 28f31e932c04beb6a12711146e0609d7732ca2b4 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Mon, 12 Oct 2026 17:38:54 +0000 Subject: [PATCH 1/5] feat(agentd): an image-set default user for execs that omit `user` (#595) A start request that omits `user`, from `exec` or `run --exec`, runs as the daemon's uid, which is root, so every workload is guest root unless each caller remembers to demote it. The daemon now reads `AGENTD_DEFAULT_USER` from the image's environment at startup. A start that omits `user` resolves that string at each request exactly as it would resolve it in `user` (AGENTD-22), a request's own `user` still runs as given, `root` and `0` included (AGENTD-23), and a value the guest's /etc/passwd lacks refuses the start as `unknown_user` with a detail that mentions the variable (AGENTD-24). Unset, nothing changes. Workload hook handlers still run as root. `/v1/health` doesn't report the default: `Health` is constructible field by field, and a new field fails `semver:check` (`constructible_struct_adds_field`, measured), so the daemon logs it at startup instead. The agent recipe's `chown` of /workspace and a sync's in-guest deletions need root, so they ask for uid 0 by number instead of omitting `user`. The model, the Gherkin tier, the fuzz harness and the handler tests cover the three new requirements; the live layer is waived because the live images set no default. --- changelog.d/595.added.md | 10 + crates/agentd/src/config.rs | 79 ++++ crates/agentd/src/exec.rs | 86 ++++- crates/agentd/src/exec_start.rs | 182 +++++++++- crates/agentd/src/exec_start_fuzz.rs | 72 +++- crates/agentd/src/hook_handlers.rs | 37 +- crates/agentd/tests/bdd_exec_start.rs | 86 +++-- .../agentd/tests/features/exec_start.feature | 49 ++- crates/microvms-app/src/agents/mod.rs | 14 +- crates/microvms-app/src/session/mod.rs | 7 + crates/microvms-app/src/workspace.rs | 7 + crates/microvms-cli/src/cli.rs | 6 +- crates/microvms-cli/src/commands/lifecycle.rs | 5 +- crates/microvms-core/README.md | 5 +- crates/model/src/exec_start.rs | 329 +++++++++++++++-- crates/protocol/src/exec.rs | 8 +- docs/PROTOCOL.md | 22 +- docs/TRACEABILITY.md | 37 ++ docs/TRUST.md | 12 +- docs/manifest.json | 4 +- docs/schema.json | 2 +- .../operations/write-a-guest-dockerfile.md | 10 +- verify/guards/faults/default-user.toml | 120 +++++++ ...eletion-path-is-refused-not-executed.patch | 339 ++++++++++++++++++ ...l-access-uploads-then-chowns-as-root.patch | 339 ++++++++++++++++++ verify/spec/agentd.symspec.json | 57 +++ verify/spec/traced/AGENTD.toml | 15 + 27 files changed, 1841 insertions(+), 98 deletions(-) create mode 100644 changelog.d/595.added.md create mode 100644 verify/guards/faults/default-user.toml create mode 100644 verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch create mode 100644 verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch diff --git a/changelog.d/595.added.md b/changelog.d/595.added.md new file mode 100644 index 00000000..1eb11d80 --- /dev/null +++ b/changelog.d/595.added.md @@ -0,0 +1,10 @@ +- **An image can set the user an exec without `user` runs as (#595).** The daemon reads + `AGENTD_DEFAULT_USER` from the image's environment at startup: a user name, or digits read as + a uid. A start request that omits `user`, from `exec` or `run --exec` alike, resolves it at + each start exactly as it would resolve that string in `user`, so a user an earlier exec created + resolves on the next. A request's own `user` still runs as given, `root` and `0` included. If + the guest's `/etc/passwd` lacks the value, each start without a `user` answers + `400 unknown_user`, and the detail mentions the variable. With it unset, nothing changes. + Workload hook handlers still run as root. The agent recipe's `chown` of `/workspace` and a + sync's in-guest deletions now ask for uid 0 by number, so an image's default user can't make + them fail. diff --git a/crates/agentd/src/config.rs b/crates/agentd/src/config.rs index da3513a8..24deb935 100644 --- a/crates/agentd/src/config.rs +++ b/crates/agentd/src/config.rs @@ -93,8 +93,25 @@ pub struct Config { /// The group database a start request's group name is resolved against. `/etc/group` /// outside tests, for `passwd_path`'s reason. pub group_path: std::path::PathBuf, + /// The user a start request runs as when it names no `user`, from + /// [`DEFAULT_USER_VAR`] (AGENTD-22). `None` keeps the daemon's own user, which is root. + /// + /// Kept as the image wrote it and resolved per request exactly as a request's `user` + /// string is: a passwd name, or digits that name no row as a uid. Per request because a + /// user an earlier exec created resolves on the next. A request's own `user` always wins, + /// `root` and `0` included (AGENTD-23), so an authorized caller can still ask for root. + /// + /// Read from the image's environment like every other `AGENTD_*` setting, and never from + /// a request or the launch environment: who a workload runs as when its caller doesn't + /// say is the image author's decision. Workload hook handlers don't take it; they are + /// image-owned code and run as the daemon's user. + pub default_user: Option, } +/// The variable naming [`Config::default_user`]. A refusal of the default names it, so an +/// image author who misspelled the user can find where it was set. +pub const DEFAULT_USER_VAR: &str = "AGENTD_DEFAULT_USER"; + /// The handler budget's ceiling: five seconds under the platform's 60 s hook limit. pub const MAX_HOOK_HANDLER_SECS: u64 = 55; @@ -128,6 +145,7 @@ impl Default for Config { hook_handler_timeout: Duration::from_secs(20), passwd_path: std::path::PathBuf::from("/etc/passwd"), group_path: std::path::PathBuf::from("/etc/group"), + default_user: None, } } } @@ -175,10 +193,32 @@ impl Config { if let Some(secs) = env_parse::("AGENTD_HOOK_HANDLER_TIMEOUT_SECS") { cfg.hook_handler_timeout = Duration::from_secs(secs.clamp(1, MAX_HOOK_HANDLER_SECS)); } + cfg.default_user = default_user(std::env::var_os(DEFAULT_USER_VAR)); + // Logged to the image's log group because `/v1/health` can't carry it yet: `Health` is + // constructible field by field, so a new field is a breaking change to the protocol + // crate. + if let Some(user) = &cfg.default_user { + tracing::info!( + default_user = %user, + "a start request that omits user runs as {DEFAULT_USER_VAR}" + ); + } cfg } } +/// [`DEFAULT_USER_VAR`] as the daemon keeps it: set means set, an empty value included. +/// +/// Unlike the numeric knobs, a value that names nobody is not ignored. It resolves the way a +/// request's `user` string does, so an empty or misspelled one refuses every start that +/// names no user, naming the variable, instead of quietly running the workload as root. That +/// costs no reachability: the daemon still boots, and a request that names its user still +/// runs. A value that isn't UTF-8 is kept lossily, which no passwd name matches, so it is +/// refused the same way rather than dropped. +fn default_user(raw: Option) -> Option { + raw.map(|raw| raw.to_string_lossy().into_owned()) +} + /// Parses a boolean knob, accepting the spellings a Dockerfile or a Terraform /// template actually produces. `bool::from_str` takes only `true`/`false`, so /// `AGENTD_REPAIR_IDENTITY=0` would otherwise be ignored with a warning — and a @@ -219,5 +259,44 @@ mod tests { // single noisy command can kill a daemon nothing can restart. assert!(cfg.max_output_bytes < 64 * 1024 * 1024); assert!(cfg.max_drain_bytes < cfg.max_body_bytes); + assert_eq!(cfg.default_user, None, "unset keeps the daemon's own user"); + } + + /// **AGENTD-22.** The default user is kept as the image set it: unset is none, and any + /// set value, empty or not UTF-8 included, is kept to be resolved, so a value that names + /// nobody is refused at the first start instead of falling back to root. + #[test] + fn the_default_user_is_kept_as_set() { + use std::os::unix::ffi::OsStringExt; + assert_eq!(default_user(None), None); + assert_eq!(default_user(Some("agent".into())), Some("agent".into())); + assert_eq!(default_user(Some("1000".into())), Some("1000".into())); + assert_eq!(default_user(Some("".into())), Some(String::new())); + let lossy = default_user(Some(std::ffi::OsString::from_vec(vec![b'a', 0xff]))) + .expect("a non-UTF-8 value is kept"); + assert_eq!(lossy, "a\u{fffd}"); + } + + /// **AGENTD-22.** `from_env` reads the default user from the daemon's environment. The + /// read happens in a copy of this test binary started with the variable set, because this + /// crate can't set its own environment: `set_var` is `unsafe`, which the crate forbids. + #[test] + fn from_env_reads_the_default_user_from_the_environment() { + const CHILD: &str = "AGENTD_TEST_FROM_ENV_CHILD"; + const NAME: &str = "config::tests::from_env_reads_the_default_user_from_the_environment"; + if std::env::var_os(CHILD).is_some() { + assert_eq!(Config::from_env().default_user.as_deref(), Some("probe")); + return; + } + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", NAME]) + .env(CHILD, "1") + .env(DEFAULT_USER_VAR, "probe") + .output() + .expect("child test"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "{stdout}"); + // A filter that matched nothing would pass too, having checked nothing. + assert!(stdout.contains("1 passed"), "{stdout}"); } } diff --git a/crates/agentd/src/exec.rs b/crates/agentd/src/exec.rs index e4007b7e..9b9fc01d 100644 --- a/crates/agentd/src/exec.rs +++ b/crates/agentd/src/exec.rs @@ -1202,10 +1202,13 @@ fn build_command( let launch_env = state.launch_env(); let config = state.config(); // Read per request rather than cached: a workload that runs `useradd` before its next - // exec is asking for exactly that user, and the files are a few kilobytes. - let passwd = match req.user { - Some(_) => read_database(&config.passwd_path), - None => String::new(), + // exec is asking for exactly that user, and the files are a few kilobytes. The image's + // default user is read the same way, for the same reason (AGENTD-22). + let default_user = config.default_user.as_deref(); + let passwd = if req.user.is_some() || default_user.is_some() { + read_database(&config.passwd_path) + } else { + String::new() }; let group = match req.group { Some(protocol::exec::NameOrId::Name(_)) => read_database(&config.group_path), @@ -1218,6 +1221,7 @@ fn build_command( image_env: state.image_env(), passwd: &passwd, group: &group, + default_user, is_executable: &is_executable_file, }, )?; @@ -2946,6 +2950,15 @@ mod tests { /// `/home/tester`) and whose group file lists `crew` (this process's gid), holding /// `inherited` as the environment it inherited at startup, bootstrapped with `launch`. fn guest(inherited: &[(&str, &str)], launch: &[(&str, &str)]) -> (AppState, tempfile::TempDir) { + guest_with_default(inherited, launch, None) + } + + /// [`guest`] whose image sets `AGENTD_DEFAULT_USER` to `default_user`. + fn guest_with_default( + inherited: &[(&str, &str)], + launch: &[(&str, &str)], + default_user: Option<&str>, + ) -> (AppState, tempfile::TempDir) { let dir = tempfile::tempdir().expect("a guest directory"); let (uid, gid) = (own_id("-u"), own_id("-g")); std::fs::write( @@ -2966,6 +2979,7 @@ mod tests { let config = Config { passwd_path: dir.path().join("passwd"), group_path: dir.path().join("group"), + default_user: default_user.map(str::to_string), ..Config::default() }; let state = AppState::with_image_env(config, launch_env(inherited)); @@ -3075,6 +3089,70 @@ mod tests { assert_eq!(outcome.stdout.trim(), "/from-launch tester"); } + /// **AGENTD-22, AGENTD-24.** Through the real handler, a start that names no user runs as + /// the image's default user, with that row's identity. The default is resolved at each + /// request: while the guest has no such user the start is 400 `unknown_user` naming the + /// variable and nothing runs, and once a row for it appears the next start runs as it. + #[tokio::test] + async fn a_start_that_names_no_user_runs_as_the_default_resolved_per_request() { + let probe = "echo \"$(id -u) $HOME $USER\""; + let (state, _dir) = guest_with_default(&[], &[], Some("tester")); + let mut request = req("default-tester", &[probe]); + request.shell = true.into(); + let outcome = started(&state, request).await; + assert_eq!( + outcome.stdout.trim(), + format!("{} /home/tester tester", own_id("-u")) + ); + + let (state, dir) = guest_with_default(&[], &[], Some("latecomer")); + let marker = dir.path().join("ran"); + let mut request = req("default-missing", &[&format!("touch {}", marker.display())]); + request.shell = true.into(); + let detail = refused(&state, request, "unknown_user", &marker).await; + assert!( + detail.contains("\"latecomer\"") && detail.contains("AGENTD_DEFAULT_USER"), + "{detail}" + ); + + // What a `useradd` in an earlier exec leaves behind. + let mut passwd = std::fs::read_to_string(dir.path().join("passwd")).expect("passwd"); + passwd.push_str(&format!( + "latecomer:x:{}:{}::/home/latecomer:/bin/sh\n", + own_id("-u"), + own_id("-g") + )); + std::fs::write(dir.path().join("passwd"), passwd).expect("passwd"); + let mut request = req("default-late", &[probe]); + request.shell = true.into(); + let outcome = started(&state, request).await; + assert_eq!( + outcome.stdout.trim(), + format!("{} /home/latecomer latecomer", own_id("-u")) + ); + } + + /// **AGENTD-23.** A user the start names wins over the default. The default here is an + /// account this non-root test cannot become (uid 65534), so a daemon that applied it + /// would fail the spawn; naming `tester` runs as `tester`, by name and by uid. + #[tokio::test] + async fn a_user_the_start_names_wins_over_the_default() { + let (state, dir) = guest_with_default(&[], &[], Some("elsewhere")); + let mut passwd = std::fs::read_to_string(dir.path().join("passwd")).expect("passwd"); + passwd.push_str("elsewhere:x:65534:65534::/nonexistent:/bin/sh\n"); + std::fs::write(dir.path().join("passwd"), passwd).expect("passwd"); + + let mut request = req("named-wins", &["/bin/sh", "-c", "echo $(id -u) $USER"]); + request.user = Some("tester".into()); + let outcome = started(&state, request).await; + assert_eq!(outcome.stdout.trim(), format!("{} tester", own_id("-u"))); + + let mut request = req("uid-wins", &["/bin/sh", "-c", "echo $(id -u) $USER"]); + request.user = Some(own_id("-u").into()); + let outcome = started(&state, request).await; + assert_eq!(outcome.stdout.trim(), format!("{} tester", own_id("-u"))); + } + /// **AGENTD-14.** `shell: "bash"` runs the script under bash: a pipefail pipeline fails /// where a POSIX `sh` that is dash would refuse the option outright, and `$0` names bash. #[tokio::test] diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs index 4694e558..1dfcce02 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs @@ -19,6 +19,15 @@ //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). //! +//! # A request that names no user +//! +//! It runs as the image's default user when the image sets `AGENTD_DEFAULT_USER`, and as the +//! daemon's own user (root) when it doesn't (AGENTD-22). The default is resolved here, per +//! request, exactly as a request's `user` string would be, and a default that names nobody +//! is refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's +//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default +//! decides what a caller gets by not saying, not what a caller may ask for. +//! //! # The environment is five layers, lowest first //! //! 1. The image environment, only when the request sets `inherit_image_env`: what the @@ -52,6 +61,8 @@ use protocol::exec::{ NameOrId, Shell, StartRequest, }; +use crate::config::DEFAULT_USER_VAR; + /// A child's environment, or one layer of it. pub type Env = HashMap; @@ -159,6 +170,8 @@ pub struct Guest<'a> { pub passwd: &'a str, /// The group file's text. Empty when the file is absent. pub group: &'a str, + /// The image's default user (`Config::default_user`), for a request that names none. + pub default_user: Option<&'a str>, /// Whether a path is an executable regular file. The real check is /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. pub is_executable: &'a dyn Fn(&Path) -> bool, @@ -246,6 +259,35 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, } } +/// The user a request resolves to: its own `user` when it names one (AGENTD-23), else the +/// image's default resolved as that string would be (AGENTD-22), else none, which is the +/// daemon's own user. +/// +/// A default that names nobody is `unknown_user` like a request's unknown name (AGENTD-24), +/// with a detail that says the request named no user and which variable set the one that +/// failed, because the caller sent no name to look for and the fix is in the image. +fn resolve_request_user<'a>( + request: &StartRequest, + default_user: Option<&str>, + rows: &'a [PasswdRow], +) -> Result>, Rejection> { + match (&request.user, default_user) { + (Some(spec), _) => resolve_user(spec, rows).map(Some), + (None, Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows) + .map(Some) + .map_err(|_| { + Rejection::new( + ERROR_UNKNOWN_USER, + format!( + "the request names no user, and the default user {name:?} that \ + {DEFAULT_USER_VAR} sets is not in the guest's /etc/passwd" + ), + ) + }), + (None, None) => Ok(None), + } +} + /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { match spec { @@ -321,16 +363,12 @@ pub fn resolve_shell( /// environment's `PATH`), so the first field that cannot be resolved names the error. The /// empty-argv check comes last, as it did in `build_command` before this module existed. pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { - let passwd = if request.user.is_some() { + let passwd = if request.user.is_some() || guest.default_user.is_some() { parse_passwd(guest.passwd) } else { Vec::new() }; - let user = request - .user - .as_ref() - .map(|spec| resolve_user(spec, &passwd)) - .transpose()?; + let user = resolve_request_user(request, guest.default_user, &passwd)?; let groups = match &request.group { Some(NameOrId::Name(_)) => parse_group(guest.group), @@ -472,6 +510,24 @@ mod tests { request: &StartRequest, launch: &Env, image: Option<&Env>, + ) -> Result { + resolve_in(request, launch, image, None) + } + + /// [`resolve`] against a guest whose image sets `default_user`, with no launch or image + /// environment. + fn resolve_with_default( + request: &StartRequest, + default_user: Option<&str>, + ) -> Result { + resolve_in(request, &Env::new(), None, default_user) + } + + fn resolve_in( + request: &StartRequest, + launch: &Env, + image: Option<&Env>, + default_user: Option<&str>, ) -> Result { let exec = only(&["/usr/bin/bash", "/bin/sh", "/image/bin/zsh"]); plan( @@ -481,6 +537,7 @@ mod tests { image_env: image, passwd: PASSWD, group: GROUP, + default_user, is_executable: &exec, }, ) @@ -708,6 +765,119 @@ mod tests { ); } + /// **AGENTD-22.** A request that names no user resolves the image's default exactly as + /// it would resolve that string: a name to its row's uid, primary gid and identity, + /// digits that name no row to that uid with the daemon's group, and a named group still + /// overrides the primary. With no default it runs as the daemon, as it always did. + #[test] + fn a_request_that_names_no_user_resolves_the_default_as_its_user() { + let nameless = request(None, None, false.into()); + + let plan = resolve_with_default(&nameless, None).expect("resolves"); + assert_eq!( + (plan.uid, plan.gid), + (None, None), + "no default, the daemon's" + ); + assert!(plan.environment().is_empty(), "no default, no identity"); + + let plan = resolve_with_default(&nameless, Some("agent")).expect("resolves"); + assert_eq!((plan.uid, plan.gid), (Some(1000), Some(1001))); + assert_eq!( + plan.environment(), + env(&[ + ("HOME", "/home/agent"), + ("USER", "agent"), + ("LOGNAME", "agent") + ]) + ); + assert_eq!( + plan, + resolve_with_default(&request(Some("agent".into()), None, false.into()), None) + .expect("resolves"), + "the default resolves exactly as the same name in the request" + ); + + let plan = resolve_with_default(&nameless, Some("4242")).expect("resolves"); + assert_eq!((plan.uid, plan.gid), (Some(4242), None)); + assert!(plan.environment().is_empty()); + let plan = resolve_with_default(&nameless, Some("1000")).expect("resolves"); + assert_eq!((plan.uid, plan.gid), (Some(1000), None)); + assert_eq!(plan.environment()["USER"], "agent"); + + let grouped = request(None, Some("staff".into()), false.into()); + let plan = resolve_with_default(&grouped, Some("agent")).expect("resolves"); + assert_eq!((plan.uid, plan.gid), (Some(1000), Some(50))); + } + + /// **AGENTD-23.** A user the request names wins over the default, `root` and `0` + /// included, and an unknown one is refused for its own name rather than replaced by the + /// default; a default that names nobody doesn't stop a request that names someone. + #[test] + fn a_user_the_request_names_wins_over_the_default() { + let root = resolve_with_default( + &request(Some("root".into()), None, false.into()), + Some("agent"), + ) + .expect("resolves"); + assert_eq!((root.uid, root.gid), (Some(0), Some(0))); + assert_eq!(root.environment()["HOME"], "/root"); + + let zero = + resolve_with_default(&request(Some(0.into()), None, false.into()), Some("agent")) + .expect("resolves"); + assert_eq!( + (zero.uid, zero.gid), + (Some(0), None), + "an integer keeps the group" + ); + + let builder = resolve_with_default( + &request(Some("builder".into()), None, false.into()), + Some("ghost"), + ) + .expect("an unknown default is never resolved for a request that names a user"); + assert_eq!((builder.uid, builder.gid), (Some(1002), Some(1002))); + + let refused = resolve_with_default( + &request(Some("ghost".into()), None, false.into()), + Some("agent"), + ) + .expect_err("an unknown user the request names"); + assert_eq!(refused.error, ERROR_UNKNOWN_USER); + assert!(refused.detail.contains("\"ghost\""), "{}", refused.detail); + assert!( + !refused.detail.contains(DEFAULT_USER_VAR), + "the caller named this user, so the detail is about it: {}", + refused.detail + ); + } + + /// **AGENTD-24.** A default that names nobody, empty included, refuses a request that + /// names no user as `unknown_user`, with a detail naming the value and the variable, + /// and it is resolved first, ahead of the group and the shell. + #[test] + fn a_default_that_names_nobody_is_refused_naming_the_variable() { + for default in ["ghost", "", "12a"] { + let refused = resolve_with_default(&request(None, None, false.into()), Some(default)) + .expect_err(default); + assert_eq!(refused.error, ERROR_UNKNOWN_USER, "{default:?}"); + assert!( + refused.detail.contains(&format!("{default:?}")) + && refused.detail.contains(DEFAULT_USER_VAR), + "{}", + refused.detail + ); + } + let all_bad = request(None, Some("ghosts".into()), "fish".into()); + assert_eq!( + resolve_with_default(&all_bad, Some("ghost")) + .unwrap_err() + .error, + ERROR_UNKNOWN_USER + ); + } + /// **AGENTD-12.** The snapshot drops every `AGENTD_` variable and anything not UTF-8. #[test] fn the_snapshot_drops_agentd_configuration_and_non_utf8() { diff --git a/crates/agentd/src/exec_start_fuzz.rs b/crates/agentd/src/exec_start_fuzz.rs index dc81e096..e4996960 100644 --- a/crates/agentd/src/exec_start_fuzz.rs +++ b/crates/agentd/src/exec_start_fuzz.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -//! The fuzz harness for AGENTD-7 through AGENTD-16: start requests whose `user`, `group` and -//! `shell` are every JSON spelling the union types admit, deserialized and resolved against -//! a fuzzed guest. +//! The fuzz harness for AGENTD-7 through AGENTD-16 and AGENTD-22 through AGENTD-24: start +//! requests whose `user`, `group` and `shell` are every JSON spelling the union types admit, +//! deserialized and resolved against a fuzzed guest whose image may set a default user. //! //! `bolero::check!` runs this as an ordinary `#[test]` under stable `cargo test`, and as a //! coverage-guided target under @@ -12,8 +12,8 @@ //! //! A start body built as JSON (so the union deserialization is exercised, not bypassed), //! a passwd file of fuzzed rows, a launch environment, the environment the daemon -//! "inherited" (which may carry `AGENTD_*` keys and a token-shaped value), and a fake -//! filesystem saying which shells exist. Names, keys and values come from small pools so +//! "inherited" (which may carry `AGENTD_*` keys and a token-shaped value), the image's +//! default user if it sets one, and a fake filesystem saying which shells exist. Names, keys and values come from small pools so //! the interesting collisions — a name that matches a row, digits, a key set in two //! layers — happen on almost every case rather than almost never. //! @@ -25,7 +25,10 @@ //! layers (AGENTD-9); the image layer is absent without the flag (AGENTD-10) and lowest with it //! (AGENTD-11); no `AGENTD_*` image key and no token reaches the plan (AGENTD-12); the snapshot's //! count is its non-`AGENTD_` keys (AGENTD-13); a named shell runs as the file it resolved to -//! (AGENTD-14); integers and booleans mean what they did (AGENTD-16). A second check feeds raw +//! (AGENTD-14); integers and booleans mean what they did (AGENTD-16); a request that names no +//! user resolves the default as it would that string (AGENTD-22), a request's own user is +//! never replaced by it (AGENTD-23), and a default that names nobody is refused naming the +//! variable (AGENTD-24). A second check feeds raw //! bytes to the `StartRequest` deserializer, which must refuse or accept but never panic. use std::path::Path; @@ -37,6 +40,7 @@ use protocol::exec::{ }; use serde_json::{Value, json}; +use crate::config::DEFAULT_USER_VAR; use crate::exec_start::{Env, Guest, Program, parse_passwd, plan, snapshot_image_env}; const NAMES: [&str; 8] = [ @@ -109,6 +113,8 @@ struct Case { inherited: Vec<(u8, u8)>, export_token: bool, rows: Vec, + /// The image's `AGENTD_DEFAULT_USER`, a [`NAMES`] index, when it sets one. + default_user: Option, } fn env_of(pairs: &[(u8, u8)]) -> Env { @@ -176,8 +182,9 @@ fn digits(raw: &str) -> Option { } /// **AGENTD-7, AGENTD-8, AGENTD-9, AGENTD-10, AGENTD-11, AGENTD-12, AGENTD-13, AGENTD-14, -/// AGENTD-15 and AGENTD-16.** A fuzzed start request resolved against a fuzzed guest keeps -/// each of the model's properties; the module docs say which assertion is which. +/// AGENTD-15, AGENTD-16, AGENTD-22, AGENTD-23 and AGENTD-24.** A fuzzed start request resolved +/// against a fuzzed guest keeps each of the model's properties; the module docs say which +/// assertion is which. #[test] fn start_resolution() { bolero::check!().with_type::().for_each(|case| { @@ -190,7 +197,10 @@ fn start_resolution() { let written = serde_json::to_value(&request).expect("serializes"); for field in ["user", "group", "shell"] { if let Some(sent) = body.get(field) { - assert_eq!(&written[field], sent, "{field} changed shape on the way back"); + assert_eq!( + &written[field], sent, + "{field} changed shape on the way back" + ); } } @@ -208,7 +218,10 @@ fn start_resolution() { assert!(snapshot.keys().all(|key| !key.starts_with("AGENTD_"))); assert_eq!( snapshot.len(), - inherited.keys().filter(|key| !key.starts_with("AGENTD_")).count() + inherited + .keys() + .filter(|key| !key.starts_with("AGENTD_")) + .count() ); let passwd = passwd_text(&case.rows); @@ -216,6 +229,7 @@ fn start_resolution() { let group = "staff:x:50:\nagent:x:1001:\n"; let launch = env_of(&case.launch_env); let exists = |path: &Path| PRESENT.iter().any(|present| Path::new(present) == path); + let default_user = case.default_user.map(|index| pick(&NAMES, index)); let result = plan( &request, &Guest { @@ -223,13 +237,20 @@ fn start_resolution() { image_env: Some(&snapshot), passwd: &passwd, group, + default_user, is_executable: &exists, }, ); - // What the resolution must decide, derived independently of it. + // What the resolution must decide, derived independently of it. The user is the + // request's own when it names one, whatever the default (AGENTD-23), and the default + // read as a name otherwise (AGENTD-22). + let user = request + .user + .clone() + .or_else(|| default_user.map(|name| NameOrId::Name(name.to_string()))); let user_row = |name: &str| rows.iter().find(|row| row.name == name); - let user_ok = match &request.user { + let user_ok = match &user { Some(NameOrId::Name(name)) => user_row(name).is_some() || digits(name).is_some(), _ => true, }; @@ -253,6 +274,21 @@ fn start_resolution() { ERROR_UNKNOWN_SHELL }; assert_eq!(rejection.error, expected, "{rejection:?} for {body}"); + // AGENTD-24: a refused default names itself and the variable; a refused name + // the request gave is about that name alone. + if expected == ERROR_UNKNOWN_USER { + match (&request.user, default_user) { + (None, Some(name)) => assert!( + rejection.detail.contains(&format!("{name:?}")) + && rejection.detail.contains(DEFAULT_USER_VAR), + "{rejection:?}" + ), + _ => assert!( + !rejection.detail.contains(DEFAULT_USER_VAR), + "{rejection:?}" + ), + } + } if let Shell::Named(name) = &request.shell && expected == ERROR_UNKNOWN_SHELL { @@ -260,19 +296,22 @@ fn start_resolution() { } } Ok(plan) => { - assert!(user_ok && group_ok, "an unresolvable name was planned: {body}"); + assert!( + user_ok && group_ok, + "an unresolvable name was planned: {body}" + ); let env = plan.environment(); // AGENTD-7 and AGENTD-16: the uid is the row's for a matching name, the number // otherwise; the gid is the group's, a named user's primary, or unchanged. - let row = match &request.user { + let row = match &user { Some(NameOrId::Name(name)) => user_row(name).or_else(|| { digits(name).and_then(|uid| rows.iter().find(|row| row.uid == uid)) }), Some(NameOrId::Id(uid)) => rows.iter().find(|row| row.uid == *uid), None => None, }; - match &request.user { + match &user { Some(NameOrId::Name(name)) => match user_row(name) { Some(named) => assert_eq!(plan.uid, Some(named.uid)), None => assert_eq!(plan.uid, digits(name)), @@ -281,7 +320,8 @@ fn start_resolution() { None => assert_eq!(plan.uid, None), } if request.group.is_none() { - let named = matches!(&request.user, Some(NameOrId::Name(name)) if user_row(name).is_some()); + let named = + matches!(&user, Some(NameOrId::Name(name)) if user_row(name).is_some()); let expected = if named { row.map(|row| row.gid) } else { None }; assert_eq!(plan.gid, expected, "{body}"); } diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs index 3a6fbbdd..1549ac9f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs @@ -24,7 +24,9 @@ //! //! A handler is image-owned code and runs as the daemon's user (root), with the //! daemon's environment plus the launch environment and `AGENTD_HOOK=`. The -//! agent token is never in either. The hook routes are unauthenticated and reachable +//! agent token is never in either. `AGENTD_DEFAULT_USER` doesn't apply: it names who a +//! start request runs as when its caller names nobody, and a handler has no caller, only +//! the image that put it there. The hook routes are unauthenticated and reachable //! over loopback from inside the guest, so any process in the VM can make a handler //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many //! times that can happen. @@ -339,6 +341,39 @@ mod tests { ); } + /// A handler runs as the daemon's own user even when the image sets a default user for + /// start requests. The default here is an account this test can't become (uid 65534), + /// so a handler demoted to it would fail to spawn rather than print this process's uid. + #[tokio::test] + async fn a_handler_runs_as_the_daemon_whatever_default_user_the_image_sets() { + let dir = tempfile::tempdir().expect("tempdir"); + let passwd = dir.path().join("passwd"); + std::fs::write(&passwd, "elsewhere:x:65534:65534::/nonexistent:/bin/sh\n").expect("passwd"); + let marker = dir.path().join("uid"); + script(dir.path(), "run", &format!("id -u > {}", marker.display())); + let state = AppState::new(Config { + hooks_dir: dir.path().to_path_buf(), + hook_handler_timeout: Duration::from_secs(5), + passwd_path: passwd, + default_user: Some("elsewhere".into()), + ..Config::default() + }); + let slot = state.record_hook("run"); + run(&state, "run", slot).await; + + let (hooks, _) = state.hook_report(); + let outcome = hooks[0].handler.as_ref().expect("an outcome"); + assert!(outcome.succeeded(), "{outcome:?}"); + let own = std::process::Command::new("/usr/bin/id") + .arg("-u") + .output() + .expect("id -u"); + assert_eq!( + std::fs::read_to_string(&marker).expect("ran"), + String::from_utf8_lossy(&own.stdout) + ); + } + /// Past the hook log's cap nothing is recorded, so nothing runs: the cap is also /// the bound on how often an in-guest caller can trigger a handler. #[tokio::test] diff --git a/crates/agentd/tests/bdd_exec_start.rs b/crates/agentd/tests/bdd_exec_start.rs index caec529a..a235186b 100644 --- a/crates/agentd/tests/bdd_exec_start.rs +++ b/crates/agentd/tests/bdd_exec_start.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -//! The Gherkin behavior spec for AGENTD-7 through AGENTD-16, run against the daemon's real -//! router in-process. +//! The Gherkin behavior spec for AGENTD-7 through AGENTD-16 and AGENTD-22 through AGENTD-24, +//! run against the daemon's real router in-process. //! //! The scenarios live in `tests/features/exec_start.feature`, tagged with the requirement each //! one verifies; this file is their step definitions and runner. It is a `harness = false` @@ -46,6 +46,8 @@ struct Daemon { dir: tempfile::TempDir, /// What the daemon inherited, before it filtered anything. inherited: HashMap, + /// The launch environment the run hook delivers. + launch: HashMap, state: Option, token: String, /// The start body being composed. @@ -79,6 +81,7 @@ impl Daemon { Self { dir: tempfile::tempdir().expect("a scenario directory"), inherited: HashMap::new(), + launch: HashMap::new(), state: None, token: String::new(), request: json!({"exec_id": EXEC_ID}), @@ -121,6 +124,32 @@ impl Daemon { fn authorized(&self, builder: axum::http::request::Builder) -> axum::http::request::Builder { builder.header("authorization", format!("Bearer {}", self.token)) } + + /// Starts a daemon over the scenario's databases, inherited environment and + /// `default_user`, and bootstraps it through the run-hook route with the scenario's token + /// and launch environment. + async fn boot(&mut self, default_user: Option) { + let config = Config { + passwd_path: self.passwd(), + group_path: self.group(), + default_user, + ..Config::default() + }; + self.state = Some(AppState::with_image_env(config, self.inherited.clone())); + let body = json!({ + "runHookPayload": serde_json::to_string(&json!({ + "agent_token": self.token, + "env": self.launch, + })) + .expect("serializes"), + }); + let request = Request::post(format!("{}/run", routes::HOOK_PREFIX)) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("a request"); + let (status, _) = self.call(request).await; + assert_eq!(status, StatusCode::OK, "the run hook bootstraps the daemon"); + } } /// `id -u` or `id -g` of this test process, as the child would print it. @@ -173,26 +202,39 @@ async fn inherited(world: &mut Daemon, step: &Step) { #[given(expr = "the run hook installed the token {string} with the launch environment:")] async fn run_hook(world: &mut Daemon, token: String, step: &Step) { - let config = Config { - passwd_path: world.passwd(), - group_path: world.group(), - ..Config::default() - }; - world.state = Some(AppState::with_image_env(config, world.inherited.clone())); - world.token = token.clone(); - let body = json!({ - "runHookPayload": serde_json::to_string(&json!({ - "agent_token": token, - "env": table(step), - })) - .expect("serializes"), - }); - let request = Request::post(format!("{}/run", routes::HOOK_PREFIX)) - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .expect("a request"); - let (status, _) = world.call(request).await; - assert_eq!(status, StatusCode::OK, "the run hook bootstraps the daemon"); + world.token = token; + world.launch = table(step); + world.boot(None).await; +} + +/// The daemon reads its configuration once, at startup, so this starts the scenario's daemon +/// again with the default set, the way an image's `ENV` line would have started it. +#[given(expr = "the image sets AGENTD_DEFAULT_USER to {string}")] +async fn default_user(world: &mut Daemon, name: String) { + world.boot(Some(name)).await; +} + +#[given( + expr = "the guest's passwd also lists {string} with this process's uid and gid and home {string}" +)] +async fn passwd_append_self(world: &mut Daemon, name: String, home: String) { + let line = format!("{name}:x:{}:{}::{home}:/bin/sh\n", own("-u"), own("-g")); + append_passwd(world, &line); +} + +#[given(expr = "the guest's passwd also lists {string} with uid and gid {int}")] +async fn passwd_append_id(world: &mut Daemon, name: String, id: u32) { + append_passwd( + world, + &format!("{name}:x:{id}:{id}::/nonexistent:/bin/sh\n"), + ); +} + +/// What a `useradd` in an earlier exec leaves behind. +fn append_passwd(world: &Daemon, line: &str) { + let mut text = std::fs::read_to_string(world.passwd()).expect("reads passwd"); + text.push_str(line); + std::fs::write(world.passwd(), text).expect("writes passwd"); } #[given(expr = "a start of {string} under shell {string}")] diff --git a/crates/agentd/tests/features/exec_start.feature b/crates/agentd/tests/features/exec_start.feature index 2a0f2772..47fa545e 100644 --- a/crates/agentd/tests/features/exec_start.feature +++ b/crates/agentd/tests/features/exec_start.feature @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # -# The behavior spec for AGENTD-7 through AGENTD-16 in verify/spec/agentd.symspec.json (issues #224, -# #225, #226). `crates/model/src/exec_start.rs` checks the same requirements as a Stateright model, +# The behavior spec for AGENTD-7 through AGENTD-16 and AGENTD-22 through AGENTD-24 in +# verify/spec/agentd.symspec.json (issues #224, #225, #226, #595). `crates/model/src/exec_start.rs` checks the same requirements as a Stateright model, # and the bolero harness in `crates/agentd/src/exec_start_fuzz.rs` fuzzes the resolution against # them. Each scenario's tags name the requirements it verifies. # @@ -197,3 +197,48 @@ Feature: A start request names its user, group and shell, and may inherit the im And the child's environment is exactly: | FROM_LAUNCH | launch | | LOGNAME | launch-logname | + + Rule: AGENTD-22 — a start that names no user runs as the image's default user + + @AGENTD-22 @AGENTD-9 + Scenario: a start that names no user runs as the default user's passwd row + Given the image sets AGENTD_DEFAULT_USER to "tester" + And a start of "id -u; id -g; echo $HOME $USER" under shell "true" + When the daemon answers the start + Then the start was accepted and the child exited 0 + And output line 1 is this process's uid + And output line 2 is this process's gid + And output line 3 is "/home/tester tester" + + @AGENTD-22 + Scenario: the default is resolved at each start, so a user added after startup resolves + Given the image sets AGENTD_DEFAULT_USER to "latecomer" + And the guest's passwd also lists "latecomer" with this process's uid and gid and home "/home/latecomer" + And a start of "echo $HOME $USER" under shell "true" + When the daemon answers the start + Then the start was accepted and the child exited 0 + And output line 1 is "/home/latecomer latecomer" + + Rule: AGENTD-23 — a user the start names wins over the default + + @AGENTD-23 + Scenario: a start that names its user runs as that user, not the default + Given the guest's passwd also lists "elsewhere" with uid and gid 65534 + And the image sets AGENTD_DEFAULT_USER to "elsewhere" + And a start of "id -u; echo $USER" under shell "true" + And the user named "tester" + When the daemon answers the start + Then the start was accepted and the child exited 0 + And output line 1 is this process's uid + And output line 2 is "tester" + + Rule: AGENTD-24 — a default the guest does not have is a 400 naming the setting, and nothing is spawned + + @AGENTD-24 + Scenario: a default user the guest does not have refuses a start that names no user + Given the image sets AGENTD_DEFAULT_USER to "nobody-here" + And a start that would leave a marker file, under shell "true" + When the daemon answers the start + Then the answer is 400 with error "unknown_user" naming "AGENTD_DEFAULT_USER" + And the answer is 400 with error "unknown_user" naming "nobody-here" + And no child was spawned diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs index ab0bd713..333dfae5 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs @@ -492,7 +492,9 @@ pub fn provisioning_files( /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// /// The daemon writes every upload as root, so the `chown` is not optional and it runs -/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call +/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user +/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is /// refreshed on a long-lived VM (AGENT-8). pub async fn install_access( @@ -510,6 +512,7 @@ pub async fn install_access( vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], ) .with_shell(true) + .with_user(Some(crate::session::ROOT_UID.into())) .with_timeout_sec(Some(CHOWN_TIMEOUT.as_secs_f64())); let result = session.run_sync(request, CHOWN_TIMEOUT).await?; if !result.succeeded() { @@ -1142,8 +1145,8 @@ mod tests { // ── install and read-back through the recorder ─────────────────────────── /// **The uploads go out in file order, then exactly one root chown.** The exec that - /// hands the workspace over runs as the daemon's own user (no `user` on the wire), - /// after every PUT. + /// hands the workspace over asks for uid 0 on the wire, after every PUT: naming no user + /// would run it as the image's default user when the image sets one. #[tokio::test(start_paused = true)] async fn install_access_uploads_then_chowns_as_root() { let recorder = Recorder::with([ @@ -1181,10 +1184,7 @@ mod tests { let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json"); assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace"); assert_eq!(start["shell"], true); - assert!( - start["user"].is_null(), - "the chown runs as the daemon's own user" - ); + assert_eq!(start["user"], 0, "the chown asks for root by uid"); assert_eq!(seen[5].path, "/v1/exec/c1/ack"); } diff --git a/crates/microvms-app/src/session/mod.rs b/crates/microvms-app/src/session/mod.rs index 16a4ab80..e83657ca 100644 --- a/crates/microvms-app/src/session/mod.rs +++ b/crates/microvms-app/src/session/mod.rs @@ -79,6 +79,13 @@ pub const DEFAULT_READY_TIMEOUT: Duration = DEFAULT_BOOTSTRAP_TIMEOUT; /// How often to re-poll health while waiting for bootstrap. const READY_POLL_INTERVAL: Duration = Duration::from_secs(2); +/// The uid this crate's own execs that need root ask for by number. +/// +/// Naming no user isn't asking for root: a daemon whose image sets `AGENTD_DEFAULT_USER` +/// runs such a start as that user. A user the request names always wins, and an integer is +/// what every daemon release accepts. +pub(crate) const ROOT_UID: u32 = 0; + /// The transport: a backend, the agent token, and the proxy auth every request needs. /// /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs index 2dbf3d00..11db62c7 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,8 +367,11 @@ impl Session { .map(String::from) .chain(doomed) .collect(); + // As root, by uid: the daemon wrote the tree as root, and an image's default user + // would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) + .with_user(Some(crate::session::ROOT_UID.into())) .with_timeout_sec(Some(delete_timeout.as_secs_f64())); let result = self.run_sync(request, delete_wait(delete_timeout)).await?; match result.outcome { @@ -611,6 +614,10 @@ mod tests { ); assert_eq!(body["cwd"], "/workspace"); assert_eq!(body["timeout_sec"], 60.0); + assert_eq!( + body["user"], 0, + "the rm asks for root by uid, which an image's default user doesn't replace" + ); assert_eq!((pass.deleted, pass.refused_deletions), (1, 1)); } diff --git a/crates/microvms-cli/src/cli.rs b/crates/microvms-cli/src/cli.rs index f3a820e7..7a4dc66e 100644 --- a/crates/microvms-cli/src/cli.rs +++ b/crates/microvms-cli/src/cli.rs @@ -978,7 +978,7 @@ pub struct RunArgs { pub launch_env: Vec<(String, String)>, /// User to run --exec's command as: a name or a numeric uid. Omitted runs as the - /// daemon's own user. + /// image's AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none. /// /// Resolved by the daemon against the guest's `/etc/passwd`, the only place the answer /// exists; see `exec --user`. @@ -1353,8 +1353,8 @@ pub struct ExecArgs { #[arg(long, value_name = "KEY=VALUE", value_parser = parse_env_pair)] pub env: Vec<(String, String)>, - /// User to run the command as: a name or a numeric uid. Omitted runs as the daemon's own - /// user. + /// User to run the command as: a name or a numeric uid. Omitted runs as the image's + /// AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none. /// /// All digits is sent as an integer uid, anything else as a name (AGENTD-7, AGENTD-16). /// The daemon resolves a name against the guest's `/etc/passwd` before it spawns anything and answers diff --git a/crates/microvms-cli/src/commands/lifecycle.rs b/crates/microvms-cli/src/commands/lifecycle.rs index ef6a544b..bf71c6ac 100644 --- a/crates/microvms-cli/src/commands/lifecycle.rs +++ b/crates/microvms-cli/src/commands/lifecycle.rs @@ -1898,7 +1898,8 @@ pub struct StartSpec<'a> { /// agent token must never leak into a child — so this map is not merged into anything: /// what is here is everything the child sees. pub env: std::collections::HashMap, - /// The user to demote to, by name or uid. `None` runs as the daemon's own user. + /// The user to demote to, by name or uid. `None` runs as the image's default user, or as + /// the daemon's own user when the image sets none. pub user: Option, /// The group to demote to, by name or gid. `None` keeps the daemon's own group, or a /// named user's primary group. @@ -1974,7 +1975,7 @@ pub fn start_request(spec: StartSpec<'_>) -> microvms_core::protocol::exec::Star // accounts are unknowable from *any* client, and the daemon resolves a name against // the guest's `/etc/passwd` and `/etc/group` before it spawns, answering // `unknown_user`/`unknown_group` for one it does not have. Omission stays the default, - // which is "run as the daemon's own user". + // which is "run as the image's `AGENTD_DEFAULT_USER`, else as the daemon's own user". .with_user(spec.user) .with_group(spec.group) // No `with_timeout_sec`: the client-side deadline is the caller's `--timeout`, applied diff --git a/crates/microvms-core/README.md b/crates/microvms-core/README.md index 8c361e69..129c3a25 100644 --- a/crates/microvms-core/README.md +++ b/crates/microvms-core/README.md @@ -111,8 +111,9 @@ transfers; `Session::run` returns an `ExecHandle` for streaming and cancellation For built-in coding agents, start with [running Claude Code or Codex](https://laithalsaadoon.github.io/microvms-agentd/learn/operations/run-coding-agents-on-bedrock/). Rust's `agents::AgentVm` composes image preparation, launch, Bedrock credential -installation, and prompts. Agent prompts run as uid/gid 1000; the generic command -example above runs as the daemon's user, normally root. +installation, and prompts. Agent prompts run as uid/gid 1000. The generic command +example above runs as the image's `AGENTD_DEFAULT_USER` when the image sets one, +and as the daemon's user, normally root, when it doesn't. The VM separates workload execution from your host. Processes within one guest share that VM; uid demotion is not a separate security boundary. Guest workloads diff --git a/crates/model/src/exec_start.rs b/crates/model/src/exec_start.rs index 788f26a5..ff364f56 100644 --- a/crates/model/src/exec_start.rs +++ b/crates/model/src/exec_start.rs @@ -5,7 +5,8 @@ //! [`crate::client`] and the output model in [`crate::output`]. It specifies AGENTD-7 through //! AGENTD-16 in `verify/spec/agentd.symspec.json`: the three protocol additions of issues #224, #225 //! and #226, which let a start request name its user and group, inherit the image's `ENV`, -//! and name its shell. +//! and name its shell. It also specifies AGENTD-22 through AGENTD-24, issue #595's default +//! user: the user an image's `AGENTD_DEFAULT_USER` names for a request that names none. //! //! # What the model settles //! @@ -34,6 +35,8 @@ //! The guest's databases are reduced to the cases that behave differently: a user given as //! an integer with and without a passwd row, a name that resolves, a name that does not, and //! an all-digit string with no row (read as a uid, the way `docker exec -u 1000` reads it). +//! The image's default user is always a string, so it takes the three string cases, or is +//! unset; the daemon reads it once at startup, so the model fixes it at [`Action::Boot`]. //! //! # Every always-property has a sometimes-property beside it //! @@ -151,7 +154,8 @@ impl Env { /// The request's `user`, reduced to the cases that behave differently. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum UserReq { - /// Omitted: the child runs as the daemon's own user. + /// Omitted: the child runs as the image's default user, or as the daemon's own user when + /// the image sets none. Absent, /// An integer uid with a passwd row. IdWithRow, @@ -165,6 +169,40 @@ pub enum UserReq { DigitsNoRow, } +/// The image's `AGENTD_DEFAULT_USER`: unset, or a string, reduced like a string `user`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum DefaultUser { + /// Not set: a request that names no user runs as the daemon's own user. + Unset, + /// A name with a passwd row. + NameKnown, + /// A name with no row, not all digits. + NameUnknown, + /// All digits naming no row: a uid. + DigitsNoRow, +} + +impl DefaultUser { + /// Every default the model boots with. + pub const ALL: [DefaultUser; 4] = [ + DefaultUser::Unset, + DefaultUser::NameKnown, + DefaultUser::NameUnknown, + DefaultUser::DigitsNoRow, + ]; + + /// The `user` a request would have to send to be resolved the same way, or `None` when + /// the default is unset. + pub fn as_request(self) -> Option { + match self { + DefaultUser::Unset => None, + DefaultUser::NameKnown => Some(UserReq::NameKnown), + DefaultUser::NameUnknown => Some(UserReq::NameUnknown), + DefaultUser::DigitsNoRow => Some(UserReq::DigitsNoRow), + } + } +} + /// The request's `group`. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum GroupReq { @@ -268,12 +306,35 @@ impl Request { && matches!(self.shell, ShellReq::Off | ShellReq::On) } - /// Whether the user resolves to a passwd row. - pub fn has_row(&self) -> bool { - matches!(self.user, UserReq::IdWithRow | UserReq::NameKnown) + /// The user the specification resolves for this request under `default`: the request's + /// own when it names one (AGENTD-23), else the default's (AGENTD-22). + pub fn user_under(&self, default: DefaultUser) -> UserReq { + match self.user { + UserReq::Absent => default.as_request().unwrap_or(UserReq::Absent), + user => user, + } + } + + /// Whether the user the specification resolves under `default` has a passwd row. + pub fn has_row(&self, default: DefaultUser) -> bool { + matches!( + self.user_under(default), + UserReq::IdWithRow | UserReq::NameKnown + ) } } +/// Whose account a child runs as. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Account { + /// The daemon's own: the request named no user and the image sets no default. + Daemon, + /// The user the request named. + Requested, + /// The image's default user. + Default, +} + /// The uid a child runs as. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Uid { @@ -321,6 +382,8 @@ pub enum Code { /// A child as spawned. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct Child { + /// Whose account `uid` and `gid` were resolved from. + pub account: Account, pub uid: Uid, pub gid: Gid, pub program: Program, @@ -342,6 +405,8 @@ pub struct World { /// The image-env layer, as this daemon reads it. pub image: Env, pub launch: Env, + /// The image's default user, read at startup. + pub default_user: DefaultUser, } /// **The specification.** What the daemon spawns for a request, or which 400 it answers. @@ -356,7 +421,27 @@ pub struct World { /// user with a passwd row was overwritten by the row. Restored after; BFS then explores /// 21,323 unique states with every property holding. pub fn specified(request: &Request, world: &World) -> Result { - plan(request, world, Layering::Specified, false, false) + plan( + request, + world, + Layering::Specified, + false, + false, + Defaulting::Specified, + ) +} + +/// How a daemon applies the image's default user. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum Defaulting { + /// To a request that names no user, resolved as that string would be. + Specified, + /// Never: the setting is read and dropped. + Ignored, + /// Ahead of the request's own user: `default.or(user)` where `user.or(default)` belongs. + OverridesRequest, + /// As specified, except a default that names nobody falls back to the daemon's user. + UnknownRunsAsDaemon, } /// Which layer order a daemon uses. @@ -376,8 +461,22 @@ fn plan( layering: Layering, numeric_primary_group: bool, shell_fallback: bool, + defaulting: Defaulting, ) -> Result { - let uid = match request.user { + // The user to resolve, and whose it is. The specification is the request's own, then the + // default's, then the daemon's. + let default = world.default_user.as_request(); + let (account, user) = match (request.user, default, defaulting) { + (_, Some(default), Defaulting::OverridesRequest) => (Account::Default, default), + (UserReq::Absent, Some(_), Defaulting::Ignored) => (Account::Daemon, UserReq::Absent), + (UserReq::Absent, Some(UserReq::NameUnknown), Defaulting::UnknownRunsAsDaemon) => { + (Account::Daemon, UserReq::Absent) + } + (UserReq::Absent, Some(default), _) => (Account::Default, default), + (UserReq::Absent, None, _) => (Account::Daemon, UserReq::Absent), + (user, _, _) => (Account::Requested, user), + }; + let uid = match user { UserReq::Absent => Uid::Daemon, UserReq::IdWithRow | UserReq::IdNoRow | UserReq::DigitsNoRow => Uid::Requested, UserReq::NameKnown => Uid::Row, @@ -387,7 +486,7 @@ fn plan( GroupReq::Id => Gid::Requested, GroupReq::NameKnown => Gid::GroupRow, GroupReq::NameUnknown => return Err(Code::UnknownGroup), - GroupReq::Absent => match request.user { + GroupReq::Absent => match user { UserReq::NameKnown => Gid::RowPrimary, UserReq::IdWithRow if numeric_primary_group => Gid::RowPrimary, _ => Gid::Daemon, @@ -407,7 +506,7 @@ fn plan( Env::default() }; let mut identity = Env::default(); - if request.has_row() { + if matches!(user, UserReq::IdWithRow | UserReq::NameKnown) { identity.set(Key::Home, Source::Passwd); identity.set(Key::User, Source::Passwd); } @@ -421,6 +520,7 @@ fn plan( env.overlay(layer); } Ok(Child { + account, uid, gid, program, @@ -455,6 +555,13 @@ pub enum Behavior { ShellFallback, /// Reports the live environment's key count on health, `AGENTD_*` included. HealthCountsLiveEnv, + /// Reads the default user and never applies it. + DefaultIgnored, + /// Applies the default user ahead of the one the request names. + DefaultOverridesRequest, + /// Runs a request that names no user as the daemon when the default names nobody: the + /// silent fallback to root an image author would not see. + UnknownDefaultRunsAsDaemon, } impl Behavior { @@ -498,12 +605,19 @@ impl Behavior { Behavior::ImageOnTop => Layering::ImageOnTop, _ => Layering::Specified, }; + let defaulting = match self { + Behavior::DefaultIgnored => Defaulting::Ignored, + Behavior::DefaultOverridesRequest => Defaulting::OverridesRequest, + Behavior::UnknownDefaultRunsAsDaemon => Defaulting::UnknownRunsAsDaemon, + _ => Defaulting::Specified, + }; plan( &request, world, layering, self == Behavior::NumericPrimaryGroup, self == Behavior::ShellFallback, + defaulting, ) } @@ -534,6 +648,8 @@ pub enum Step { pub struct State { /// The process has started and taken its snapshot. pub booted: bool, + /// The image's default user, read at startup. + pub default_user: DefaultUser, /// The daemon's own process environment right now. pub daemon_env: Env, /// The startup snapshot, when this daemon takes one. @@ -556,8 +672,8 @@ pub struct State { #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Action { /// The daemon starts as the container `CMD` with the image environment and its own - /// `AGENTD_*` configuration. - Boot, + /// `AGENTD_*` configuration, which sets this default user. + Boot(DefaultUser), /// The platform's run hook, carrying the token and a launch environment. RunHook { home: bool, path: bool }, /// `POST /v1/exec/start`. @@ -598,22 +714,29 @@ impl ExecStart { World { image: self.behavior.image_layer(state), launch: state.launch, + default_user: state.default_user, } } /// Spawns whatever a spawn-first daemon can run before it has resolved anything: the - /// command under `/bin/sh` as the daemon's own user. + /// command under `/bin/sh` as the daemon's own user, which is also what it runs before + /// it has resolved the default. fn blind_child(&self, state: &State, request: &Request) -> Child { let mut unresolved = *request; unresolved.user = UserReq::Absent; unresolved.group = GroupReq::Absent; unresolved.shell = ShellReq::On; + let world = World { + default_user: DefaultUser::Unset, + ..self.world(state) + }; plan( &unresolved, - &self.world(state), + &world, Layering::Specified, false, false, + Defaulting::Specified, ) .expect("an unresolved request always plans") } @@ -626,6 +749,7 @@ impl Model for ExecStart { fn init_states(&self) -> Vec { vec![State { booted: false, + default_user: DefaultUser::Unset, daemon_env: Env::default(), snapshot: None, token_installed: false, @@ -641,7 +765,9 @@ impl Model for ExecStart { fn actions(&self, state: &State, actions: &mut Vec) { if !state.booted { - actions.push(Action::Boot); + for default_user in DefaultUser::ALL { + actions.push(Action::Boot(default_user)); + } return; } if state.health.is_none() { @@ -675,8 +801,9 @@ impl Model for ExecStart { fn next_state(&self, last: &State, action: Action) -> Option { let mut next = *last; match action { - Action::Boot => { + Action::Boot(default_user) => { next.booted = true; + next.default_user = default_user; next.daemon_env = Self::boot_env(); next.snapshot = self.behavior.snapshot(&next.daemon_env); } @@ -793,7 +920,7 @@ impl Model for ExecStart { Property::::always( "AGENTD-9 HOME and USER follow request over launch over passwd", |_, s| match started(s) { - Some((r, c)) if r.has_row() => { + Some((r, c)) if r.has_row(s.default_user) => { let home = if r.sets_home { Source::Request } else if s.launch.get(Key::Home).is_some() { @@ -818,7 +945,7 @@ impl Model for ExecStart { "AGENTD-9 witness: a request HOME overrides the passwd HOME", |_, s| { started(s).is_some_and(|(r, c)| { - r.has_row() && c.env.get(Key::Home) == Some(Source::Request) + r.has_row(s.default_user) && c.env.get(Key::Home) == Some(Source::Request) }) }, ), @@ -858,7 +985,7 @@ impl Model for ExecStart { Source::Request } else if launch(Key::Home) { Source::Launch - } else if r.has_row() { + } else if r.has_row(s.default_user) { Source::Passwd } else { Source::Image @@ -956,10 +1083,16 @@ impl Model for ExecStart { |_, s| refused(s).is_some_and(|(_, code)| code == Code::UnknownShell), ), // ── AGENTD-16 ───────────────────────────────────────────────────── + // Over requests that name their user, or under an image that sets no default: a + // request that names nobody under a default is AGENTD-22's. Property::::always( "AGENTD-16 integer ids and a boolean shell keep their protocol-1 meaning", |_, s| match started(s) { - Some((r, c)) if r.is_protocol_one() => { + Some((r, c)) + if r.is_protocol_one() + && (r.user != UserReq::Absent + || s.default_user == DefaultUser::Unset) => + { let uid = match r.user { UserReq::Absent => Uid::Daemon, _ => Uid::Requested, @@ -981,6 +1114,87 @@ impl Model for ExecStart { "AGENTD-16 witness: an integer user with a passwd row runs", |_, s| started(s).is_some_and(|(r, _)| r.user == UserReq::IdWithRow), ), + // ── AGENTD-22 ───────────────────────────────────────────────────── + // + // A default that names nobody never starts, which is AGENTD-24's to say. + Property::::always( + "AGENTD-22 a request that names no user runs as the default user", + |_, s| match started(s) { + Some((r, c)) if r.user == UserReq::Absent => match s.default_user { + DefaultUser::Unset => c.account == Account::Daemon && c.uid == Uid::Daemon, + DefaultUser::NameKnown => { + c.account == Account::Default + && c.uid == Uid::Row + && (r.group != GroupReq::Absent || c.gid == Gid::RowPrimary) + && c.env.get(Key::User) == Some(Source::Passwd) + } + DefaultUser::DigitsNoRow => { + c.account == Account::Default + && c.uid == Uid::Requested + && (r.group != GroupReq::Absent || c.gid == Gid::Daemon) + && c.env.get(Key::User).is_none() + } + DefaultUser::NameUnknown => true, + }, + _ => true, + }, + ), + Property::::sometimes( + "AGENTD-22 witness: a request that names no user runs as a named default", + |_, s| { + started(s).is_some_and(|(r, _)| { + r.user == UserReq::Absent && s.default_user == DefaultUser::NameKnown + }) + }, + ), + Property::::sometimes( + "AGENTD-22 witness: a request that names no user runs as an all-digit default", + |_, s| { + started(s).is_some_and(|(r, _)| { + r.user == UserReq::Absent && s.default_user == DefaultUser::DigitsNoRow + }) + }, + ), + // ── AGENTD-23 ───────────────────────────────────────────────────── + Property::::always( + "AGENTD-23 a user the request names wins over the default", + |_, s| { + started(s).is_none_or(|(r, c)| { + r.user == UserReq::Absent || c.account == Account::Requested + }) + }, + ), + Property::::sometimes( + "AGENTD-23 witness: a request names a user under a default and runs as it", + |_, s| { + started(s).is_some_and(|(r, _)| { + r.user != UserReq::Absent && s.default_user != DefaultUser::Unset + }) + }, + ), + // ── AGENTD-24 ───────────────────────────────────────────────────── + Property::::always( + "AGENTD-24 a default that names nobody is refused unknown_user and spawns no child", + |_, s| match (&s.request, s.answer) { + (Some(r), Some(answer)) + if r.user == UserReq::Absent + && s.default_user == DefaultUser::NameUnknown => + { + answer == Answer::Refused(Code::UnknownUser) && s.child.is_none() + } + _ => true, + }, + ), + Property::::sometimes( + "AGENTD-24 witness: a default that names nobody is refused", + |_, s| { + refused(s).is_some_and(|(r, code)| { + r.user == UserReq::Absent + && s.default_user == DefaultUser::NameUnknown + && code == Code::UnknownUser + }) + }, + ), // ── liveness ────────────────────────────────────────────────────── // // Sound because the model is acyclic: every action moves a state that no action @@ -1001,8 +1215,9 @@ mod tests { ExecStart::new(behavior).checker().spawn_bfs().join() } - /// The headline: the specified daemon satisfies AGENTD-7 through AGENTD-16 for every - /// request against every launch environment, and witnesses each case. + /// The headline: the specified daemon satisfies AGENTD-7 through AGENTD-16 and AGENTD-22 + /// through AGENTD-24 for every request against every launch environment and every + /// default user, and witnesses each case. #[test] fn the_specified_daemon_satisfies_every_property() { let checker = checked(Behavior::Specified); @@ -1120,6 +1335,42 @@ mod tests { .assert_any_discovery("AGENTD-13 health reports the snapshot's key count"); } + /// **AGENTD-22.** A default that is read and never applied leaves a request that names + /// nobody running as the daemon, which is root. + #[test] + fn an_ignored_default_runs_a_nameless_request_as_the_daemon() { + checked(Behavior::DefaultIgnored).assert_any_discovery( + "AGENTD-22 a request that names no user runs as the default user", + ); + } + + /// **AGENTD-23.** A default applied ahead of the request's own user takes away a + /// caller's way to ask for root, or for anyone else. + #[test] + fn a_default_ahead_of_the_requests_user_overrides_the_caller() { + let checker = checked(Behavior::DefaultOverridesRequest); + checker.assert_any_discovery("AGENTD-23 a user the request names wins over the default"); + checker + .assert_no_discovery("AGENTD-22 a request that names no user runs as the default user"); + } + + /// **AGENTD-24.** Falling back to the daemon when the default names nobody runs the + /// workload as root with no signal, which is the misconfiguration the setting exists to + /// prevent. + #[test] + fn an_unknown_default_falling_back_to_the_daemon_starts_as_root() { + let checker = checked(Behavior::UnknownDefaultRunsAsDaemon); + let path = checker + .assert_any_discovery( + "AGENTD-24 a default that names nobody is refused unknown_user and spawns no child", + ) + .into_actions(); + assert!( + path.contains(&Action::Boot(DefaultUser::NameUnknown)), + "the fallback needs a default that names nobody, got {path:?}" + ); + } + /// The table the daemon's tests mirror: the specification's answer for the cases the /// issues name. #[test] @@ -1129,6 +1380,7 @@ mod tests { let world = World { image: ExecStart::boot_env().without_agentd(), launch, + default_user: DefaultUser::Unset, }; let base = Request { user: UserReq::Absent, @@ -1139,9 +1391,10 @@ mod tests { sets_path: false, }; - // Flag unset, no user: exactly the launch map. + // Flag unset, no user: exactly the launch map, as the daemon. let child = specified(&base, &world).expect("starts"); assert_eq!(child.env, launch); + assert_eq!((child.account, child.uid), (Account::Daemon, Uid::Daemon)); // A named user: its row's uid and primary gid, HOME and USER from the row. let named = Request { @@ -1189,6 +1442,38 @@ mod tests { assert_eq!(specified(&request, &world), Err(code), "{request:?}"); } + // A default user: a request that names nobody resolves it as the same name would + // resolve, one that names someone keeps its own, and one that names nobody under a + // default that names nobody is refused. + let defaulted = World { + default_user: DefaultUser::NameKnown, + ..world + }; + let child = specified(&base, &defaulted).expect("starts"); + assert_eq!(child.account, Account::Default); + assert_eq!((child.uid, child.gid, child.env), { + let named = specified(&named, &world).expect("starts"); + (named.uid, named.gid, named.env) + }); + let own = Request { + user: UserReq::IdNoRow, + ..base + }; + let child = specified(&own, &defaulted).expect("starts"); + assert_eq!( + (child.account, child.uid, child.gid), + (Account::Requested, Uid::Requested, Gid::Daemon) + ); + let nobody = World { + default_user: DefaultUser::NameUnknown, + ..world + }; + assert_eq!(specified(&base, &nobody), Err(Code::UnknownUser)); + assert_eq!( + specified(&own, &nobody).map(|child| child.account), + Ok(Account::Requested) + ); + // Flag set: image < passwd < launch < request. let inheriting = Request { inherit_image_env: true, diff --git a/crates/protocol/src/exec.rs b/crates/protocol/src/exec.rs index bba07545..b5728183 100644 --- a/crates/protocol/src/exec.rs +++ b/crates/protocol/src/exec.rs @@ -232,8 +232,9 @@ pub struct StartRequest { #[serde(default)] pub env: HashMap, /// The user to demote to: a numeric uid, or a name resolved against the guest's - /// `/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the - /// guest does not have answers 400 `unknown_user` before anything is spawned. + /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as + /// that string here would be, or the daemon's own user when the image sets none. A name + /// the guest does not have answers 400 `unknown_user` before anything is spawned. /// /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME` /// from the row, beneath the launch environment and `env`. A user given by name also @@ -550,7 +551,8 @@ impl StartRequest { self } - /// The user to demote to; `None` runs as the daemon's own user. + /// The user to demote to; `None` runs as the image's `AGENTD_DEFAULT_USER`, or as the + /// daemon's own user when the image sets none. pub fn with_user(mut self, user: Option) -> Self { self.user = user; self diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7474e8fb..42454e1b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -128,8 +128,9 @@ log group), never returned on `/v1/health`, which needs no agent token. A service that needs to keep running after its handler exits should redirect its output streams so the daemon can finish reading them. -A handler runs as the daemon's user (root) with the daemon's environment, the -launch environment, and `AGENTD_HOOK=`; never the agent token. Any process +A handler runs as the daemon's user (root), whatever user `AGENTD_DEFAULT_USER` +holds, with the daemon's environment, the launch environment, and +`AGENTD_HOOK=`; never the agent token. Any process in the guest can post a hook path, so a handler must be safe to run spuriously. See [Trust](TRUST.md). @@ -339,6 +340,23 @@ as before. A user given by *name* also gets the row's primary gid when `group` i omitted, the way `su` does; a user given as an integer keeps the daemon's group, which is what protocol 1 always did. +**An image can set the user a start without `user` runs as.** The image's +`AGENTD_DEFAULT_USER` holds a user name or a string of digits. A start request +that omits `user` resolves that string exactly as it would resolve it in +`user`. A name gets its row's uid, primary gid, `HOME`, `USER` and `LOGNAME`, +and digits that match no name are a uid. The daemon resolves it at each +request, so a user an earlier exec created resolves on the next. A request's +own `user` always wins, `root` and `0` included, and `group` still overrides the +primary gid. If the guest's `/etc/passwd` doesn't hold the value, the daemon +answers each start that omits `user` with `400 unknown_user`, and the `detail` +mentions `AGENTD_DEFAULT_USER`. The daemon refuses an empty value the same way. It still +boots, and a request that sets `user` still runs. With the variable unset, a +start that omits `user` runs as the daemon's own user, root, as it always has. +Workload hook handlers ignore the variable, and the image snapshot drops it with +every other `AGENTD_*` variable. `/v1/health` doesn't report it, because a new +`Health` field would break the protocol crate's API. The daemon logs the value +at startup instead. + **A named shell is resolved on the child's `PATH`, the image's `PATH`, `/bin`, then `/usr/bin`.** The first executable regular file wins and runs as ` -c `. An absolute path is checked as given. A shell the guest diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 99878d75..4aed49e8 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -28,6 +28,9 @@ traced requirement is missing a layer. Requirements are defined in | AGENTD-19 | 1 | waived | waived | 1 | 1 | waived | | AGENTD-20 | 1 | waived | 1 | 3 | 1 | waived | | AGENTD-21 | 1 | waived | 1 | 1 | 1 | waived | +| AGENTD-22 | 1 | 1 | 1 | 3 | 3 | waived | +| AGENTD-23 | 1 | 1 | 1 | 2 | 2 | waived | +| AGENTD-24 | 1 | 1 | 1 | 2 | 1 | waived | | ARCH-1 | waived | waived | waived | 1 | 2 | waived | | ARCH-2 | waived | waived | waived | 1 | 4 | waived | | ARCH-3 | waived | waived | waived | 1 | 1 | waived | @@ -351,6 +354,39 @@ If a frame of an identity-verified tunnel does not authenticate at the frame's p - **impl:** `crates/agentd/src/tunnel.rs` - **live:** waived: a live daemon and the endpoint proxy neither replay nor forge a frame, so a live run can't present one; the relay tests send both to the daemon's real route +## AGENTD-22 + +Where the image environment sets the default user, the agentd shall resolve the default user against the guest's /etc/passwd as the user of each start request that names no user. + +- **model:** `crates/model/src/exec_start.rs` +- **gherkin:** `crates/agentd/tests/features/exec_start.feature` +- **fuzz:** `crates/agentd/src/exec_start_fuzz.rs` +- **test:** `crates/agentd/src/config.rs`, `crates/agentd/src/exec.rs`, `crates/agentd/src/exec_start.rs` +- **impl:** `crates/agentd/src/config.rs`, `crates/agentd/src/exec.rs`, `crates/agentd/src/exec_start.rs` +- **live:** waived: the live suite's images set no AGENTD_DEFAULT_USER, and one that did would change who every other live exec runs as; the handler tests and the Gherkin tier spawn real children under a configured default + +## AGENTD-23 + +Where the image environment sets the default user, the agentd shall demote the child of a start request that names its user to the named user rather than the default user. + +- **model:** `crates/model/src/exec_start.rs` +- **gherkin:** `crates/agentd/tests/features/exec_start.feature` +- **fuzz:** `crates/agentd/src/exec_start_fuzz.rs` +- **test:** `crates/agentd/src/exec.rs`, `crates/agentd/src/exec_start.rs` +- **impl:** `crates/agentd/src/config.rs`, `crates/agentd/src/exec_start.rs` +- **live:** waived: the live suite's images set no AGENTD_DEFAULT_USER, and one that did would change who every other live exec runs as; the handler tests and the Gherkin tier spawn real children under a configured default + +## AGENTD-24 + +If a start request names no user and the guest's /etc/passwd does not contain the default user, then the agentd shall answer Bad Request with the unknown_user error naming the default user setting and spawn no child. + +- **model:** `crates/model/src/exec_start.rs` +- **gherkin:** `crates/agentd/tests/features/exec_start.feature` +- **fuzz:** `crates/agentd/src/exec_start_fuzz.rs` +- **test:** `crates/agentd/src/exec.rs`, `crates/agentd/src/exec_start.rs` +- **impl:** `crates/agentd/src/exec_start.rs` +- **live:** waived: the live suite's images set no AGENTD_DEFAULT_USER, and one that did would change who every other live exec runs as; the handler tests and the Gherkin tier refuse a start under a default the guest does not have + ## ARCH-1 The microvms-core shall be the single library crate the CLI crate and the language bindings depend on, composing microvms-app with microvms-edges and re-exporting microvms-domain. @@ -1365,6 +1401,7 @@ trace it. | A guest answers a verified tunnel's handshake with a key other than the pinned VM key | BIND-21 | `crates/microvms-core/tests/tunnel_end_to_end.rs::a_wrong_pin_fails_closed_with_a_diagnosis`, `crates/microvms-core/tests/tunnel_end_to_end.rs::a_reply_that_does_not_verify_against_the_pin_fails_the_tunnel` | guarded | | A guest or the path replays, reorders or forges tunnel frames after the handshake | AGENTD-21, BIND-24 | `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_replayed_frame_ends_the_tunnel_unrelayed`, `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_forged_frame_ends_the_tunnel_unrelayed`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_replayed_frame_fails_the_tunnel_unwritten`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_forged_frame_fails_the_tunnel_unwritten`, `crates/model/src/tunnel.rs::bind_24_the_model_finds_a_replay_when_frames_carry_no_position`, `crates/agentd/src/tunnel_fuzz.rs::frames_open_in_order_and_the_callers_end_only_after_every_chunk`, `crates/microvms-edges/src/session/tunnel_fuzz.rs::frames_open_in_order_and_the_daemons_end_only_after_every_chunk` | guarded | | A guest streams hostile server-sent events to the client | BIND-22 | `crates/microvms-app/src/session/sse.rs::an_unterminated_stream_is_refused_at_the_pending_ceiling`, `crates/microvms-app/src/session/sse.rs::an_unrecognized_or_unparseable_frame_is_dropped_rather_than_raised`, `crates/microvms-app/src/session/sse_fuzz.rs::hostile_stream_bytes_stay_bounded_and_every_event_round_trips` | guarded | +| A workload whose caller omits `user` runs as guest root | AGENTD-22, AGENTD-24 | `crates/agentd/src/exec.rs::a_start_that_names_no_user_runs_as_the_default_resolved_per_request`, `crates/agentd/src/exec_start.rs::a_default_that_names_nobody_is_refused_naming_the_variable`, `crates/model/src/exec_start.rs::an_unknown_default_falling_back_to_the_daemon_starts_as_root` | guarded when the image sets `AGENTD_DEFAULT_USER`. Without it such a workload runs as root, and with it an authorized caller can still ask for root | | A replaced or tampered daemon release asset | BIND-18 | `crates/microvms-edges/src/provision/release.rs::another_signer_identity_is_refused`, `crates/microvms-edges/src/provision/release.rs::one_flipped_byte_in_the_asset_is_refused` | guarded | | A release directory (`$MICROVM_RELEASE_DIR`) holding an asset and a matching `SHA256SUMS` but no bundle, or another release's files under the requested tag | BIND-18 | `crates/microvms-edges/src/provision/release.rs::a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum`, `crates/microvms-edges/src/provision/release.rs::another_releases_files_under_the_requested_tag_are_refused` | guarded | | An on-path party ends a verified tunnel early, with a plaintext close frame or by dropping the connection | BIND-23, AGENTD-19, AGENTD-20 | `crates/microvms-edges/tests/tunnel_end_of_stream.rs::a_verified_tcp_consumer_observes_the_stream_verdict`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_close_frame_forged_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_connection_dropped_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_the_daemons_end_of_stream_withheld_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::agentd_20_the_clients_end_of_stream_withheld_resets_the_guest`, `crates/agentd/tests/tunnel_relay.rs::agentd_19_a_guest_eof_reaches_the_caller_as_the_end_of_stream` | guarded. A protocol 2 peer that omits the end-of-stream offer ends `ClosedUnproven`. Each side refuses a protocol 1 handshake | diff --git a/docs/TRUST.md b/docs/TRUST.md index 9d3e2ff7..97701531 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -19,6 +19,13 @@ daemon memory with `ptrace` or `/proc//mem`, change files, or exhaust guest resources. User demotion changes the workload UID within the same sandbox. The model and authentication tests do not prove isolation from guest root. +A start request that omits `user` runs as root unless the image sets +`AGENTD_DEFAULT_USER`. With it set, that start runs as the image's user, and a +value the guest lacks refuses the start instead of running it as root. The +setting decides what a caller gets without asking. An authorized caller can +still execute as root by asking for `root` or `0`, and workload hook handlers +still run as root. + ## Platform isolation guarantees AWS documents two relevant properties: @@ -118,6 +125,7 @@ and it renders the table in [Traceability](TRACEABILITY.md). A guard is written | A guest answers a verified tunnel's handshake with a key other than the pinned VM key | `BIND-21` | `crates/microvms-core/tests/tunnel_end_to_end.rs::a_wrong_pin_fails_closed_with_a_diagnosis`, `crates/microvms-core/tests/tunnel_end_to_end.rs::a_reply_that_does_not_verify_against_the_pin_fails_the_tunnel` | guarded | | A guest or the path replays, reorders or forges tunnel frames after the handshake | `AGENTD-21`, `BIND-24` | `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_replayed_frame_ends_the_tunnel_unrelayed`, `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_forged_frame_ends_the_tunnel_unrelayed`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_replayed_frame_fails_the_tunnel_unwritten`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_forged_frame_fails_the_tunnel_unwritten`, `crates/model/src/tunnel.rs::bind_24_the_model_finds_a_replay_when_frames_carry_no_position`, `crates/agentd/src/tunnel_fuzz.rs::frames_open_in_order_and_the_callers_end_only_after_every_chunk`, `crates/microvms-edges/src/session/tunnel_fuzz.rs::frames_open_in_order_and_the_daemons_end_only_after_every_chunk` | guarded | | A guest streams hostile server-sent events to the client | `BIND-22` | `crates/microvms-app/src/session/sse.rs::an_unterminated_stream_is_refused_at_the_pending_ceiling`, `crates/microvms-app/src/session/sse.rs::an_unrecognized_or_unparseable_frame_is_dropped_rather_than_raised`, `crates/microvms-app/src/session/sse_fuzz.rs::hostile_stream_bytes_stay_bounded_and_every_event_round_trips` | guarded | +| A workload whose caller omits `user` runs as guest root | `AGENTD-22`, `AGENTD-24` | `crates/agentd/src/exec.rs::a_start_that_names_no_user_runs_as_the_default_resolved_per_request`, `crates/agentd/src/exec_start.rs::a_default_that_names_nobody_is_refused_naming_the_variable`, `crates/model/src/exec_start.rs::an_unknown_default_falling_back_to_the_daemon_starts_as_root` | guarded when the image sets `AGENTD_DEFAULT_USER`. Without it such a workload runs as root, and with it an authorized caller can still ask for root | | A replaced or tampered daemon release asset | `BIND-18` | `crates/microvms-edges/src/provision/release.rs::another_signer_identity_is_refused`, `crates/microvms-edges/src/provision/release.rs::one_flipped_byte_in_the_asset_is_refused` | guarded | | A release directory (`$MICROVM_RELEASE_DIR`) holding an asset and a matching `SHA256SUMS` but no bundle, or another release's files under the requested tag | `BIND-18` | `crates/microvms-edges/src/provision/release.rs::a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum`, `crates/microvms-edges/src/provision/release.rs::another_releases_files_under_the_requested_tag_are_refused` | guarded | | An on-path party ends a verified tunnel early, with a plaintext close frame or by dropping the connection | `BIND-23`, `AGENTD-19`, `AGENTD-20` | `crates/microvms-edges/tests/tunnel_end_of_stream.rs::a_verified_tcp_consumer_observes_the_stream_verdict`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_close_frame_forged_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_connection_dropped_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_the_daemons_end_of_stream_withheld_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::agentd_20_the_clients_end_of_stream_withheld_resets_the_guest`, `crates/agentd/tests/tunnel_relay.rs::agentd_19_a_guest_eof_reaches_the_caller_as_the_end_of_stream` | guarded. A protocol 2 peer that omits the end-of-stream offer ends `ClosedUnproven`. Each side refuses a protocol 1 handshake | @@ -195,8 +203,8 @@ An executable at `/` runs when the platform posts that lifecycle hook ([Protocol](PROTOCOL.md), "Workload hook handlers"). It is image-owned code with the daemon's privileges: -- It runs as root, like the daemon, with the daemon's environment, the launch - environment, and `AGENTD_HOOK`. The agent token is never in either +- It runs as root, like the daemon, whatever user `AGENTD_DEFAULT_USER` holds, + with the daemon's environment, the launch environment, and `AGENTD_HOOK`. The agent token is never in either environment. Keep secrets out of shared images; a handler is part of the image. - The hook routes are unauthenticated and reachable over loopback from inside the guest, so any guest process can make a handler run. A handler must be diff --git a/docs/manifest.json b/docs/manifest.json index 4e8778ef..cc281ac1 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -231,7 +231,7 @@ { "choices": null, "default": null, - "help": "User to run --exec's command as: a name or a numeric uid. Omitted runs as the daemon's own user", + "help": "User to run --exec's command as: a name or a numeric uid. Omitted runs as the image's AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none", "name": "user", "positional": false, "required": false, @@ -1408,7 +1408,7 @@ { "choices": null, "default": null, - "help": "User to run the command as: a name or a numeric uid. Omitted runs as the daemon's own user", + "help": "User to run the command as: a name or a numeric uid. Omitted runs as the image's AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none", "name": "user", "positional": false, "required": false, diff --git a/docs/schema.json b/docs/schema.json index 45f5126d..4b430c83 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -631,7 +631,7 @@ } ], "default": null, - "description": "The user to demote to: a numeric uid, or a name resolved against the guest's\n`/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the\nguest does not have answers 400 `unknown_user` before anything is spawned.\n\nA user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`\nfrom the row, beneath the launch environment and `env`. A user given by name also\ngets the row's primary gid when `group` is omitted; a numeric uid keeps the\ndaemon's group, as it always did." + "description": "The user to demote to: a numeric uid, or a name resolved against the guest's\n`/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as\nthat string here would be, or the daemon's own user when the image sets none. A name\nthe guest does not have answers 400 `unknown_user` before anything is spawned.\n\nA user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`\nfrom the row, beneath the launch environment and `env`. A user given by name also\ngets the row's primary gid when `group` is omitted; a numeric uid keeps the\ndaemon's group, as it always did." } }, "required": [ diff --git a/site/authored/learn/operations/write-a-guest-dockerfile.md b/site/authored/learn/operations/write-a-guest-dockerfile.md index b96baa78..43c2f557 100644 --- a/site/authored/learn/operations/write-a-guest-dockerfile.md +++ b/site/authored/learn/operations/write-a-guest-dockerfile.md @@ -71,6 +71,14 @@ WORKDIR /workspace The daemon itself stays root; demotion is per command, with `exec --user 1000 --group 1000`. [Run coding agents on Bedrock](/learn/operations/run-coding-agents-on-bedrock/) shows why that uid is not decoration. +To make that user the default for every `exec` and `run --exec` without `--user`, set it in the image: + +```dockerfile +ENV AGENTD_DEFAULT_USER=agent +``` + +The daemon resolves the value at each exec, the way it resolves `--user agent`, so the row can come from an earlier exec. An exec with `--user` runs as that user instead, `--user root` included, and hook handlers still run as root. + ## 5. No secret goes in the image The image becomes a shared snapshot, so every VM launched from it sees the same bytes. Per-VM credentials are delivered through the platform's one-shot `runHookPayload` at launch: the agent token always, and anything you add with `run --launch-env KEY=VALUE`, which shares the payload's 4096-byte ceiling. Larger material goes in after bootstrap with `microvm cp --mode 0600` over the authenticated channel. @@ -81,7 +89,7 @@ The artifact `microvm build` uploads carries exactly the Dockerfile and the `age ## 7. The daemon's knobs -Every `AGENTD_*` variable is read at startup, and an unset or unparseable value keeps the default rather than refusing to boot, because a daemon that will not start strands the VM with no way in. Set them as `ENV` lines. The stanza already sets `AGENTD_PORT` and `AGENTD_LOG`; the others bound request bodies, captured output, stream replay, stdin writes, the disk reserve, and the identity repair that gives each VM restored from one snapshot its own machine-id, hostname, and boot_id. The daemon configuration is defined in `crates/agentd/src/config.rs`; +Every `AGENTD_*` variable is read at startup, and an unset or unparseable value keeps the default rather than refusing to boot, because a daemon that will not start strands the VM with no way in. `AGENTD_DEFAULT_USER` doesn't fall back: a user the guest lacks fails each exec without `--user` with `unknown_user`, and the error mentions the variable. Set them as `ENV` lines. The stanza already sets `AGENTD_PORT` and `AGENTD_LOG`; the others bound request bodies, captured output, stream replay, stdin writes, the disk reserve, and the identity repair that gives each VM restored from one snapshot its own machine-id, hostname, and boot_id. The daemon configuration is defined in `crates/agentd/src/config.rs`; [Protocol](/internals/protocol/) documents the request limits and hook behavior. ## 8. Run code on suspend and resume diff --git a/verify/guards/faults/default-user.toml b/verify/guards/faults/default-user.toml new file mode 100644 index 00000000..c8720f7b --- /dev/null +++ b/verify/guards/faults/default-user.toml @@ -0,0 +1,120 @@ +# ── default-user: AGENTD_DEFAULT_USER, the image's default user for a start that omits `user` (#595) ── + +# The daemon's tests call what the change adds (`Guest::default_user`, `Config::default_user`), +# so the merge base doesn't build them and FAIL_TO_PASS can't prove them. Each fault here takes +# one part of the change out where the tests still compile. + +# AGENTD-22: the default read and never applied. +[[fault]] +id = "default-user-ignored" +guard = "exec_start::tests::a_request_that_names_no_user_resolves_the_default_as_its_user" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start::tests::a_request_that_names_no_user_resolves_the_default_as_its_user"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " match (&request.user, default_user) {\n", with = " let default_user: Option<&str> = default_user.and(None);\n match (&request.user, default_user) {\n" } + +[[fault]] +id = "default-user-ignored-fuzz" +guard = "exec_start_fuzz::start_resolution" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start_fuzz::start_resolution"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " match (&request.user, default_user) {\n", with = " let default_user: Option<&str> = default_user.and(None);\n match (&request.user, default_user) {\n" } + +# AGENTD-22 through the handler: the default dropped where the start builds its `Guest`, and +# the passwd file left unread for a start that omits `user`. +[[family]] +guard = "exec::tests::a_start_that_names_no_user_runs_as_the_default_resolved_per_request" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec::tests::a_start_that_names_no_user_runs_as_the_default_resolved_per_request"] +expect = "test-failed" +suite = "rust" + +[[family.fault]] +id = "default-user-dropped-at-build" +transform = { file = "crates/agentd/src/exec.rs", replace = " default_user,\n is_executable: &is_executable_file,\n", with = " default_user: None,\n is_executable: &is_executable_file,\n" } + +[[family.fault]] +id = "default-user-passwd-unread" +transform = { file = "crates/agentd/src/exec.rs", replace = " let passwd = if req.user.is_some() || default_user.is_some() {\n", with = " let passwd = if req.user.is_some() {\n" } + +# AGENTD-23: the default applied ahead of a user the request names. +[[fault]] +id = "default-user-overrides-request" +guard = "exec_start::tests::a_user_the_request_names_wins_over_the_default" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start::tests::a_user_the_request_names_wins_over_the_default"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " (Some(spec), _) => resolve_user(spec, rows).map(Some),\n", with = " (Some(_), Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows).map(Some),\n (Some(spec), _) => resolve_user(spec, rows).map(Some),\n" } + +[[fault]] +id = "default-user-overrides-request-handler" +guard = "exec::tests::a_user_the_start_names_wins_over_the_default" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec::tests::a_user_the_start_names_wins_over_the_default"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " (Some(spec), _) => resolve_user(spec, rows).map(Some),\n", with = " (Some(_), Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows).map(Some),\n (Some(spec), _) => resolve_user(spec, rows).map(Some),\n" } + +# AGENTD-24: a default the guest doesn't have falls back to the daemon's user, which is root. +[[fault]] +id = "default-user-unknown-runs-as-daemon" +guard = "exec_start::tests::a_default_that_names_nobody_is_refused_naming_the_variable" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start::tests::a_default_that_names_nobody_is_refused_naming_the_variable"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " .map(Some)\n .map_err(|_| {\n", with = " .map(Some)\n .or(Ok::<_, Rejection>(None))\n .map_err(|_| {\n" } + +# An empty or non-UTF-8 value read as unset, which would run every workload as root. +[[fault]] +id = "default-user-empty-read-as-unset" +guard = "config::tests::the_default_user_is_kept_as_set" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "config::tests::the_default_user_is_kept_as_set"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/config.rs", replace = " raw.map(|raw| raw.to_string_lossy().into_owned())\n", with = " raw.and_then(|raw| raw.into_string().ok())\n .filter(|raw| !raw.is_empty())\n" } + +# `from_env` that never reads the variable, the mutant `replace Config::from_env -> Self with +# Default::default()` stands for: only the child copy of the test binary sees the variable set. +[[fault]] +id = "default-user-env-unread" +guard = "config::tests::from_env_reads_the_default_user_from_the_environment" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "config::tests::from_env_reads_the_default_user_from_the_environment"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/config.rs", replace = " cfg.default_user = default_user(std::env::var_os(DEFAULT_USER_VAR));\n", with = "" } + +# A workload hook handler demoted to the image's default user (uid 65534 in the test). +[[fault]] +id = "default-user-hook-handler-demoted" +guard = "hook_handlers::tests::a_handler_runs_as_the_daemon_whatever_default_user_the_image_sets" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "hook_handlers::tests::a_handler_runs_as_the_daemon_whatever_default_user_the_image_sets"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/hook_handlers.rs", replace = " .process_group(0)\n .kill_on_drop(true);\n", with = " .process_group(0)\n .uid(65534)\n .kill_on_drop(true);\n" } + +# The model's AGENTD-22 property: a specification that runs a nameless request as the daemon +# under any default. +[[fault]] +id = "model-exec-start-default-ignored" +guard = "exec_start::tests::the_specified_daemon_satisfies_every_property" +run = ["cargo", "test", "-p", "agentd-model", "--lib", "--", "--exact", "exec_start::tests::the_specified_daemon_satisfies_every_property"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/model/src/exec_start.rs", replace = " (UserReq::Absent, Some(default), _) => (Account::Default, default),\n", with = " (UserReq::Absent, Some(_), _) => (Account::Daemon, UserReq::Absent),\n" } + +# FAIL_TO_PASS: fails on 78e5f1d22d56 with the fix taken out (tools/fail-to-pass.py). +[[fault]] +id = "f2p-install-access-uploads-then-chowns-as-root" +guard = "agents::tests::install_access_uploads_then_chowns_as_root" +run = ["cargo", "test", "-p", "microvms-app", "--lib", "--", "--exact", "agents::tests::install_access_uploads_then_chowns_as_root"] +expect = "test-failed" +suite = "rust" +patch = "verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch" + +# FAIL_TO_PASS: fails on 78e5f1d22d56 with the fix taken out (tools/fail-to-pass.py). +[[fault]] +id = "f2p-a-hostile-deletion-path-is-refused-not-executed" +guard = "workspace::tests::a_hostile_deletion_path_is_refused_not_executed" +run = ["cargo", "test", "-p", "microvms-app", "--lib", "--", "--exact", "workspace::tests::a_hostile_deletion_path_is_refused_not_executed"] +expect = "test-failed" +suite = "rust" +patch = "verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch" diff --git a/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch b/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch new file mode 100644 index 00000000..8de9de0f --- /dev/null +++ b/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch @@ -0,0 +1,339 @@ +diff --git a/crates/agentd/src/config.rs b/crates/agentd/src/config.rs +index 24deb93..9ea2777 100644 +--- a/crates/agentd/src/config.rs ++++ b/crates/agentd/src/config.rs +@@ -93,25 +93,8 @@ pub struct Config { + /// The group database a start request's group name is resolved against. `/etc/group` + /// outside tests, for `passwd_path`'s reason. + pub group_path: std::path::PathBuf, +- /// The user a start request runs as when it names no `user`, from +- /// [`DEFAULT_USER_VAR`] (AGENTD-22). `None` keeps the daemon's own user, which is root. +- /// +- /// Kept as the image wrote it and resolved per request exactly as a request's `user` +- /// string is: a passwd name, or digits that name no row as a uid. Per request because a +- /// user an earlier exec created resolves on the next. A request's own `user` always wins, +- /// `root` and `0` included (AGENTD-23), so an authorized caller can still ask for root. +- /// +- /// Read from the image's environment like every other `AGENTD_*` setting, and never from +- /// a request or the launch environment: who a workload runs as when its caller doesn't +- /// say is the image author's decision. Workload hook handlers don't take it; they are +- /// image-owned code and run as the daemon's user. +- pub default_user: Option, + } + +-/// The variable naming [`Config::default_user`]. A refusal of the default names it, so an +-/// image author who misspelled the user can find where it was set. +-pub const DEFAULT_USER_VAR: &str = "AGENTD_DEFAULT_USER"; +- + /// The handler budget's ceiling: five seconds under the platform's 60 s hook limit. + pub const MAX_HOOK_HANDLER_SECS: u64 = 55; + +@@ -145,7 +128,6 @@ impl Default for Config { + hook_handler_timeout: Duration::from_secs(20), + passwd_path: std::path::PathBuf::from("/etc/passwd"), + group_path: std::path::PathBuf::from("/etc/group"), +- default_user: None, + } + } + } +@@ -193,32 +175,10 @@ impl Config { + if let Some(secs) = env_parse::("AGENTD_HOOK_HANDLER_TIMEOUT_SECS") { + cfg.hook_handler_timeout = Duration::from_secs(secs.clamp(1, MAX_HOOK_HANDLER_SECS)); + } +- cfg.default_user = default_user(std::env::var_os(DEFAULT_USER_VAR)); +- // Logged to the image's log group because `/v1/health` can't carry it yet: `Health` is +- // constructible field by field, so a new field is a breaking change to the protocol +- // crate. +- if let Some(user) = &cfg.default_user { +- tracing::info!( +- default_user = %user, +- "a start request that omits user runs as {DEFAULT_USER_VAR}" +- ); +- } + cfg + } + } + +-/// [`DEFAULT_USER_VAR`] as the daemon keeps it: set means set, an empty value included. +-/// +-/// Unlike the numeric knobs, a value that names nobody is not ignored. It resolves the way a +-/// request's `user` string does, so an empty or misspelled one refuses every start that +-/// names no user, naming the variable, instead of quietly running the workload as root. That +-/// costs no reachability: the daemon still boots, and a request that names its user still +-/// runs. A value that isn't UTF-8 is kept lossily, which no passwd name matches, so it is +-/// refused the same way rather than dropped. +-fn default_user(raw: Option) -> Option { +- raw.map(|raw| raw.to_string_lossy().into_owned()) +-} +- + /// Parses a boolean knob, accepting the spellings a Dockerfile or a Terraform + /// template actually produces. `bool::from_str` takes only `true`/`false`, so + /// `AGENTD_REPAIR_IDENTITY=0` would otherwise be ignored with a warning — and a +diff --git a/crates/agentd/src/exec.rs b/crates/agentd/src/exec.rs +index 9b9fc01..0f53011 100644 +--- a/crates/agentd/src/exec.rs ++++ b/crates/agentd/src/exec.rs +@@ -1202,13 +1202,10 @@ fn build_command( + let launch_env = state.launch_env(); + let config = state.config(); + // Read per request rather than cached: a workload that runs `useradd` before its next +- // exec is asking for exactly that user, and the files are a few kilobytes. The image's +- // default user is read the same way, for the same reason (AGENTD-22). +- let default_user = config.default_user.as_deref(); +- let passwd = if req.user.is_some() || default_user.is_some() { +- read_database(&config.passwd_path) +- } else { +- String::new() ++ // exec is asking for exactly that user, and the files are a few kilobytes. ++ let passwd = match req.user { ++ Some(_) => read_database(&config.passwd_path), ++ None => String::new(), + }; + let group = match req.group { + Some(protocol::exec::NameOrId::Name(_)) => read_database(&config.group_path), +@@ -1221,7 +1218,6 @@ fn build_command( + image_env: state.image_env(), + passwd: &passwd, + group: &group, +- default_user, + is_executable: &is_executable_file, + }, + )?; +diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs +index 1dfcce0..2e5d092 100644 +--- a/crates/agentd/src/exec_start.rs ++++ b/crates/agentd/src/exec_start.rs +@@ -19,15 +19,6 @@ + //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still + //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). + //! +-//! # A request that names no user +-//! +-//! It runs as the image's default user when the image sets `AGENTD_DEFAULT_USER`, and as the +-//! daemon's own user (root) when it doesn't (AGENTD-22). The default is resolved here, per +-//! request, exactly as a request's `user` string would be, and a default that names nobody +-//! is refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's +-//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default +-//! decides what a caller gets by not saying, not what a caller may ask for. +-//! + //! # The environment is five layers, lowest first + //! + //! 1. The image environment, only when the request sets `inherit_image_env`: what the +@@ -61,8 +52,6 @@ use protocol::exec::{ + NameOrId, Shell, StartRequest, + }; + +-use crate::config::DEFAULT_USER_VAR; +- + /// A child's environment, or one layer of it. + pub type Env = HashMap; + +@@ -170,8 +159,6 @@ pub struct Guest<'a> { + pub passwd: &'a str, + /// The group file's text. Empty when the file is absent. + pub group: &'a str, +- /// The image's default user (`Config::default_user`), for a request that names none. +- pub default_user: Option<&'a str>, + /// Whether a path is an executable regular file. The real check is + /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. + pub is_executable: &'a dyn Fn(&Path) -> bool, +@@ -259,35 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, + } + } + +-/// The user a request resolves to: its own `user` when it names one (AGENTD-23), else the +-/// image's default resolved as that string would be (AGENTD-22), else none, which is the +-/// daemon's own user. +-/// +-/// A default that names nobody is `unknown_user` like a request's unknown name (AGENTD-24), +-/// with a detail that says the request named no user and which variable set the one that +-/// failed, because the caller sent no name to look for and the fix is in the image. +-fn resolve_request_user<'a>( +- request: &StartRequest, +- default_user: Option<&str>, +- rows: &'a [PasswdRow], +-) -> Result>, Rejection> { +- match (&request.user, default_user) { +- (Some(spec), _) => resolve_user(spec, rows).map(Some), +- (None, Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows) +- .map(Some) +- .map_err(|_| { +- Rejection::new( +- ERROR_UNKNOWN_USER, +- format!( +- "the request names no user, and the default user {name:?} that \ +- {DEFAULT_USER_VAR} sets is not in the guest's /etc/passwd" +- ), +- ) +- }), +- (None, None) => Ok(None), +- } +-} +- + /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). + fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { + match spec { +@@ -363,12 +321,16 @@ pub fn resolve_shell( + /// environment's `PATH`), so the first field that cannot be resolved names the error. The + /// empty-argv check comes last, as it did in `build_command` before this module existed. + pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { +- let passwd = if request.user.is_some() || guest.default_user.is_some() { ++ let passwd = if request.user.is_some() { + parse_passwd(guest.passwd) + } else { + Vec::new() + }; +- let user = resolve_request_user(request, guest.default_user, &passwd)?; ++ let user = request ++ .user ++ .as_ref() ++ .map(|spec| resolve_user(spec, &passwd)) ++ .transpose()?; + + let groups = match &request.group { + Some(NameOrId::Name(_)) => parse_group(guest.group), +diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs +index 1549ac9..b30f398 100644 +--- a/crates/agentd/src/hook_handlers.rs ++++ b/crates/agentd/src/hook_handlers.rs +@@ -24,9 +24,7 @@ + //! + //! A handler is image-owned code and runs as the daemon's user (root), with the + //! daemon's environment plus the launch environment and `AGENTD_HOOK=`. The +-//! agent token is never in either. `AGENTD_DEFAULT_USER` doesn't apply: it names who a +-//! start request runs as when its caller names nobody, and a handler has no caller, only +-//! the image that put it there. The hook routes are unauthenticated and reachable ++//! agent token is never in either. The hook routes are unauthenticated and reachable + //! over loopback from inside the guest, so any process in the VM can make a handler + //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many + //! times that can happen. +diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs +index 333dfae..6ca3bcc 100644 +--- a/crates/microvms-app/src/agents/mod.rs ++++ b/crates/microvms-app/src/agents/mod.rs +@@ -492,9 +492,7 @@ pub fn provisioning_files( + /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). + /// + /// The daemon writes every upload as root, so the `chown` is not optional and it runs +-/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +-/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user +-/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call ++/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call + /// with a fresh token overwrites the same files, which is how a 12-hour token is + /// refreshed on a long-lived VM (AGENT-8). + pub async fn install_access( +@@ -512,7 +510,6 @@ pub async fn install_access( + vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], + ) + .with_shell(true) +- .with_user(Some(crate::session::ROOT_UID.into())) + .with_timeout_sec(Some(CHOWN_TIMEOUT.as_secs_f64())); + let result = session.run_sync(request, CHOWN_TIMEOUT).await?; + if !result.succeeded() { +diff --git a/crates/microvms-app/src/session/mod.rs b/crates/microvms-app/src/session/mod.rs +index e83657c..16a4ab8 100644 +--- a/crates/microvms-app/src/session/mod.rs ++++ b/crates/microvms-app/src/session/mod.rs +@@ -79,13 +79,6 @@ pub const DEFAULT_READY_TIMEOUT: Duration = DEFAULT_BOOTSTRAP_TIMEOUT; + /// How often to re-poll health while waiting for bootstrap. + const READY_POLL_INTERVAL: Duration = Duration::from_secs(2); + +-/// The uid this crate's own execs that need root ask for by number. +-/// +-/// Naming no user isn't asking for root: a daemon whose image sets `AGENTD_DEFAULT_USER` +-/// runs such a start as that user. A user the request names always wins, and an integer is +-/// what every daemon release accepts. +-pub(crate) const ROOT_UID: u32 = 0; +- + /// The transport: a backend, the agent token, and the proxy auth every request needs. + /// + /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session +diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs +index 11db62c..f3d4bd5 100644 +--- a/crates/microvms-app/src/workspace.rs ++++ b/crates/microvms-app/src/workspace.rs +@@ -367,11 +367,8 @@ impl Session { + .map(String::from) + .chain(doomed) + .collect(); +- // As root, by uid: the daemon wrote the tree as root, and an image's default user +- // would be refused deleting it. + let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) + .with_cwd(Some(REMOTE_WORKDIR.into())) +- .with_user(Some(crate::session::ROOT_UID.into())) + .with_timeout_sec(Some(delete_timeout.as_secs_f64())); + let result = self.run_sync(request, delete_wait(delete_timeout)).await?; + match result.outcome { +diff --git a/crates/microvms-cli/src/cli.rs b/crates/microvms-cli/src/cli.rs +index 7a4dc66..f3a820e 100644 +--- a/crates/microvms-cli/src/cli.rs ++++ b/crates/microvms-cli/src/cli.rs +@@ -978,7 +978,7 @@ pub struct RunArgs { + pub launch_env: Vec<(String, String)>, + + /// User to run --exec's command as: a name or a numeric uid. Omitted runs as the +- /// image's AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none. ++ /// daemon's own user. + /// + /// Resolved by the daemon against the guest's `/etc/passwd`, the only place the answer + /// exists; see `exec --user`. +@@ -1353,8 +1353,8 @@ pub struct ExecArgs { + #[arg(long, value_name = "KEY=VALUE", value_parser = parse_env_pair)] + pub env: Vec<(String, String)>, + +- /// User to run the command as: a name or a numeric uid. Omitted runs as the image's +- /// AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none. ++ /// User to run the command as: a name or a numeric uid. Omitted runs as the daemon's own ++ /// user. + /// + /// All digits is sent as an integer uid, anything else as a name (AGENTD-7, AGENTD-16). + /// The daemon resolves a name against the guest's `/etc/passwd` before it spawns anything and answers +diff --git a/crates/microvms-cli/src/commands/lifecycle.rs b/crates/microvms-cli/src/commands/lifecycle.rs +index bf71c6a..ef6a544 100644 +--- a/crates/microvms-cli/src/commands/lifecycle.rs ++++ b/crates/microvms-cli/src/commands/lifecycle.rs +@@ -1898,8 +1898,7 @@ pub struct StartSpec<'a> { + /// agent token must never leak into a child — so this map is not merged into anything: + /// what is here is everything the child sees. + pub env: std::collections::HashMap, +- /// The user to demote to, by name or uid. `None` runs as the image's default user, or as +- /// the daemon's own user when the image sets none. ++ /// The user to demote to, by name or uid. `None` runs as the daemon's own user. + pub user: Option, + /// The group to demote to, by name or gid. `None` keeps the daemon's own group, or a + /// named user's primary group. +@@ -1975,7 +1974,7 @@ pub fn start_request(spec: StartSpec<'_>) -> microvms_core::protocol::exec::Star + // accounts are unknowable from *any* client, and the daemon resolves a name against + // the guest's `/etc/passwd` and `/etc/group` before it spawns, answering + // `unknown_user`/`unknown_group` for one it does not have. Omission stays the default, +- // which is "run as the image's `AGENTD_DEFAULT_USER`, else as the daemon's own user". ++ // which is "run as the daemon's own user". + .with_user(spec.user) + .with_group(spec.group) + // No `with_timeout_sec`: the client-side deadline is the caller's `--timeout`, applied +diff --git a/crates/protocol/src/exec.rs b/crates/protocol/src/exec.rs +index b572818..bba0754 100644 +--- a/crates/protocol/src/exec.rs ++++ b/crates/protocol/src/exec.rs +@@ -232,9 +232,8 @@ pub struct StartRequest { + #[serde(default)] + pub env: HashMap, + /// The user to demote to: a numeric uid, or a name resolved against the guest's +- /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as +- /// that string here would be, or the daemon's own user when the image sets none. A name +- /// the guest does not have answers 400 `unknown_user` before anything is spawned. ++ /// `/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the ++ /// guest does not have answers 400 `unknown_user` before anything is spawned. + /// + /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME` + /// from the row, beneath the launch environment and `env`. A user given by name also +@@ -551,8 +550,7 @@ impl StartRequest { + self + } + +- /// The user to demote to; `None` runs as the image's `AGENTD_DEFAULT_USER`, or as the +- /// daemon's own user when the image sets none. ++ /// The user to demote to; `None` runs as the daemon's own user. + pub fn with_user(mut self, user: Option) -> Self { + self.user = user; + self diff --git a/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch b/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch new file mode 100644 index 00000000..8de9de0f --- /dev/null +++ b/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch @@ -0,0 +1,339 @@ +diff --git a/crates/agentd/src/config.rs b/crates/agentd/src/config.rs +index 24deb93..9ea2777 100644 +--- a/crates/agentd/src/config.rs ++++ b/crates/agentd/src/config.rs +@@ -93,25 +93,8 @@ pub struct Config { + /// The group database a start request's group name is resolved against. `/etc/group` + /// outside tests, for `passwd_path`'s reason. + pub group_path: std::path::PathBuf, +- /// The user a start request runs as when it names no `user`, from +- /// [`DEFAULT_USER_VAR`] (AGENTD-22). `None` keeps the daemon's own user, which is root. +- /// +- /// Kept as the image wrote it and resolved per request exactly as a request's `user` +- /// string is: a passwd name, or digits that name no row as a uid. Per request because a +- /// user an earlier exec created resolves on the next. A request's own `user` always wins, +- /// `root` and `0` included (AGENTD-23), so an authorized caller can still ask for root. +- /// +- /// Read from the image's environment like every other `AGENTD_*` setting, and never from +- /// a request or the launch environment: who a workload runs as when its caller doesn't +- /// say is the image author's decision. Workload hook handlers don't take it; they are +- /// image-owned code and run as the daemon's user. +- pub default_user: Option, + } + +-/// The variable naming [`Config::default_user`]. A refusal of the default names it, so an +-/// image author who misspelled the user can find where it was set. +-pub const DEFAULT_USER_VAR: &str = "AGENTD_DEFAULT_USER"; +- + /// The handler budget's ceiling: five seconds under the platform's 60 s hook limit. + pub const MAX_HOOK_HANDLER_SECS: u64 = 55; + +@@ -145,7 +128,6 @@ impl Default for Config { + hook_handler_timeout: Duration::from_secs(20), + passwd_path: std::path::PathBuf::from("/etc/passwd"), + group_path: std::path::PathBuf::from("/etc/group"), +- default_user: None, + } + } + } +@@ -193,32 +175,10 @@ impl Config { + if let Some(secs) = env_parse::("AGENTD_HOOK_HANDLER_TIMEOUT_SECS") { + cfg.hook_handler_timeout = Duration::from_secs(secs.clamp(1, MAX_HOOK_HANDLER_SECS)); + } +- cfg.default_user = default_user(std::env::var_os(DEFAULT_USER_VAR)); +- // Logged to the image's log group because `/v1/health` can't carry it yet: `Health` is +- // constructible field by field, so a new field is a breaking change to the protocol +- // crate. +- if let Some(user) = &cfg.default_user { +- tracing::info!( +- default_user = %user, +- "a start request that omits user runs as {DEFAULT_USER_VAR}" +- ); +- } + cfg + } + } + +-/// [`DEFAULT_USER_VAR`] as the daemon keeps it: set means set, an empty value included. +-/// +-/// Unlike the numeric knobs, a value that names nobody is not ignored. It resolves the way a +-/// request's `user` string does, so an empty or misspelled one refuses every start that +-/// names no user, naming the variable, instead of quietly running the workload as root. That +-/// costs no reachability: the daemon still boots, and a request that names its user still +-/// runs. A value that isn't UTF-8 is kept lossily, which no passwd name matches, so it is +-/// refused the same way rather than dropped. +-fn default_user(raw: Option) -> Option { +- raw.map(|raw| raw.to_string_lossy().into_owned()) +-} +- + /// Parses a boolean knob, accepting the spellings a Dockerfile or a Terraform + /// template actually produces. `bool::from_str` takes only `true`/`false`, so + /// `AGENTD_REPAIR_IDENTITY=0` would otherwise be ignored with a warning — and a +diff --git a/crates/agentd/src/exec.rs b/crates/agentd/src/exec.rs +index 9b9fc01..0f53011 100644 +--- a/crates/agentd/src/exec.rs ++++ b/crates/agentd/src/exec.rs +@@ -1202,13 +1202,10 @@ fn build_command( + let launch_env = state.launch_env(); + let config = state.config(); + // Read per request rather than cached: a workload that runs `useradd` before its next +- // exec is asking for exactly that user, and the files are a few kilobytes. The image's +- // default user is read the same way, for the same reason (AGENTD-22). +- let default_user = config.default_user.as_deref(); +- let passwd = if req.user.is_some() || default_user.is_some() { +- read_database(&config.passwd_path) +- } else { +- String::new() ++ // exec is asking for exactly that user, and the files are a few kilobytes. ++ let passwd = match req.user { ++ Some(_) => read_database(&config.passwd_path), ++ None => String::new(), + }; + let group = match req.group { + Some(protocol::exec::NameOrId::Name(_)) => read_database(&config.group_path), +@@ -1221,7 +1218,6 @@ fn build_command( + image_env: state.image_env(), + passwd: &passwd, + group: &group, +- default_user, + is_executable: &is_executable_file, + }, + )?; +diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs +index 1dfcce0..2e5d092 100644 +--- a/crates/agentd/src/exec_start.rs ++++ b/crates/agentd/src/exec_start.rs +@@ -19,15 +19,6 @@ + //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still + //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). + //! +-//! # A request that names no user +-//! +-//! It runs as the image's default user when the image sets `AGENTD_DEFAULT_USER`, and as the +-//! daemon's own user (root) when it doesn't (AGENTD-22). The default is resolved here, per +-//! request, exactly as a request's `user` string would be, and a default that names nobody +-//! is refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's +-//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default +-//! decides what a caller gets by not saying, not what a caller may ask for. +-//! + //! # The environment is five layers, lowest first + //! + //! 1. The image environment, only when the request sets `inherit_image_env`: what the +@@ -61,8 +52,6 @@ use protocol::exec::{ + NameOrId, Shell, StartRequest, + }; + +-use crate::config::DEFAULT_USER_VAR; +- + /// A child's environment, or one layer of it. + pub type Env = HashMap; + +@@ -170,8 +159,6 @@ pub struct Guest<'a> { + pub passwd: &'a str, + /// The group file's text. Empty when the file is absent. + pub group: &'a str, +- /// The image's default user (`Config::default_user`), for a request that names none. +- pub default_user: Option<&'a str>, + /// Whether a path is an executable regular file. The real check is + /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. + pub is_executable: &'a dyn Fn(&Path) -> bool, +@@ -259,35 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, + } + } + +-/// The user a request resolves to: its own `user` when it names one (AGENTD-23), else the +-/// image's default resolved as that string would be (AGENTD-22), else none, which is the +-/// daemon's own user. +-/// +-/// A default that names nobody is `unknown_user` like a request's unknown name (AGENTD-24), +-/// with a detail that says the request named no user and which variable set the one that +-/// failed, because the caller sent no name to look for and the fix is in the image. +-fn resolve_request_user<'a>( +- request: &StartRequest, +- default_user: Option<&str>, +- rows: &'a [PasswdRow], +-) -> Result>, Rejection> { +- match (&request.user, default_user) { +- (Some(spec), _) => resolve_user(spec, rows).map(Some), +- (None, Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows) +- .map(Some) +- .map_err(|_| { +- Rejection::new( +- ERROR_UNKNOWN_USER, +- format!( +- "the request names no user, and the default user {name:?} that \ +- {DEFAULT_USER_VAR} sets is not in the guest's /etc/passwd" +- ), +- ) +- }), +- (None, None) => Ok(None), +- } +-} +- + /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). + fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { + match spec { +@@ -363,12 +321,16 @@ pub fn resolve_shell( + /// environment's `PATH`), so the first field that cannot be resolved names the error. The + /// empty-argv check comes last, as it did in `build_command` before this module existed. + pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { +- let passwd = if request.user.is_some() || guest.default_user.is_some() { ++ let passwd = if request.user.is_some() { + parse_passwd(guest.passwd) + } else { + Vec::new() + }; +- let user = resolve_request_user(request, guest.default_user, &passwd)?; ++ let user = request ++ .user ++ .as_ref() ++ .map(|spec| resolve_user(spec, &passwd)) ++ .transpose()?; + + let groups = match &request.group { + Some(NameOrId::Name(_)) => parse_group(guest.group), +diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs +index 1549ac9..b30f398 100644 +--- a/crates/agentd/src/hook_handlers.rs ++++ b/crates/agentd/src/hook_handlers.rs +@@ -24,9 +24,7 @@ + //! + //! A handler is image-owned code and runs as the daemon's user (root), with the + //! daemon's environment plus the launch environment and `AGENTD_HOOK=`. The +-//! agent token is never in either. `AGENTD_DEFAULT_USER` doesn't apply: it names who a +-//! start request runs as when its caller names nobody, and a handler has no caller, only +-//! the image that put it there. The hook routes are unauthenticated and reachable ++//! agent token is never in either. The hook routes are unauthenticated and reachable + //! over loopback from inside the guest, so any process in the VM can make a handler + //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many + //! times that can happen. +diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs +index 333dfae..6ca3bcc 100644 +--- a/crates/microvms-app/src/agents/mod.rs ++++ b/crates/microvms-app/src/agents/mod.rs +@@ -492,9 +492,7 @@ pub fn provisioning_files( + /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). + /// + /// The daemon writes every upload as root, so the `chown` is not optional and it runs +-/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +-/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user +-/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call ++/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call + /// with a fresh token overwrites the same files, which is how a 12-hour token is + /// refreshed on a long-lived VM (AGENT-8). + pub async fn install_access( +@@ -512,7 +510,6 @@ pub async fn install_access( + vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], + ) + .with_shell(true) +- .with_user(Some(crate::session::ROOT_UID.into())) + .with_timeout_sec(Some(CHOWN_TIMEOUT.as_secs_f64())); + let result = session.run_sync(request, CHOWN_TIMEOUT).await?; + if !result.succeeded() { +diff --git a/crates/microvms-app/src/session/mod.rs b/crates/microvms-app/src/session/mod.rs +index e83657c..16a4ab8 100644 +--- a/crates/microvms-app/src/session/mod.rs ++++ b/crates/microvms-app/src/session/mod.rs +@@ -79,13 +79,6 @@ pub const DEFAULT_READY_TIMEOUT: Duration = DEFAULT_BOOTSTRAP_TIMEOUT; + /// How often to re-poll health while waiting for bootstrap. + const READY_POLL_INTERVAL: Duration = Duration::from_secs(2); + +-/// The uid this crate's own execs that need root ask for by number. +-/// +-/// Naming no user isn't asking for root: a daemon whose image sets `AGENTD_DEFAULT_USER` +-/// runs such a start as that user. A user the request names always wins, and an integer is +-/// what every daemon release accepts. +-pub(crate) const ROOT_UID: u32 = 0; +- + /// The transport: a backend, the agent token, and the proxy auth every request needs. + /// + /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session +diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs +index 11db62c..f3d4bd5 100644 +--- a/crates/microvms-app/src/workspace.rs ++++ b/crates/microvms-app/src/workspace.rs +@@ -367,11 +367,8 @@ impl Session { + .map(String::from) + .chain(doomed) + .collect(); +- // As root, by uid: the daemon wrote the tree as root, and an image's default user +- // would be refused deleting it. + let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) + .with_cwd(Some(REMOTE_WORKDIR.into())) +- .with_user(Some(crate::session::ROOT_UID.into())) + .with_timeout_sec(Some(delete_timeout.as_secs_f64())); + let result = self.run_sync(request, delete_wait(delete_timeout)).await?; + match result.outcome { +diff --git a/crates/microvms-cli/src/cli.rs b/crates/microvms-cli/src/cli.rs +index 7a4dc66..f3a820e 100644 +--- a/crates/microvms-cli/src/cli.rs ++++ b/crates/microvms-cli/src/cli.rs +@@ -978,7 +978,7 @@ pub struct RunArgs { + pub launch_env: Vec<(String, String)>, + + /// User to run --exec's command as: a name or a numeric uid. Omitted runs as the +- /// image's AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none. ++ /// daemon's own user. + /// + /// Resolved by the daemon against the guest's `/etc/passwd`, the only place the answer + /// exists; see `exec --user`. +@@ -1353,8 +1353,8 @@ pub struct ExecArgs { + #[arg(long, value_name = "KEY=VALUE", value_parser = parse_env_pair)] + pub env: Vec<(String, String)>, + +- /// User to run the command as: a name or a numeric uid. Omitted runs as the image's +- /// AGENTD_DEFAULT_USER, or as the daemon's own user when the image sets none. ++ /// User to run the command as: a name or a numeric uid. Omitted runs as the daemon's own ++ /// user. + /// + /// All digits is sent as an integer uid, anything else as a name (AGENTD-7, AGENTD-16). + /// The daemon resolves a name against the guest's `/etc/passwd` before it spawns anything and answers +diff --git a/crates/microvms-cli/src/commands/lifecycle.rs b/crates/microvms-cli/src/commands/lifecycle.rs +index bf71c6a..ef6a544 100644 +--- a/crates/microvms-cli/src/commands/lifecycle.rs ++++ b/crates/microvms-cli/src/commands/lifecycle.rs +@@ -1898,8 +1898,7 @@ pub struct StartSpec<'a> { + /// agent token must never leak into a child — so this map is not merged into anything: + /// what is here is everything the child sees. + pub env: std::collections::HashMap, +- /// The user to demote to, by name or uid. `None` runs as the image's default user, or as +- /// the daemon's own user when the image sets none. ++ /// The user to demote to, by name or uid. `None` runs as the daemon's own user. + pub user: Option, + /// The group to demote to, by name or gid. `None` keeps the daemon's own group, or a + /// named user's primary group. +@@ -1975,7 +1974,7 @@ pub fn start_request(spec: StartSpec<'_>) -> microvms_core::protocol::exec::Star + // accounts are unknowable from *any* client, and the daemon resolves a name against + // the guest's `/etc/passwd` and `/etc/group` before it spawns, answering + // `unknown_user`/`unknown_group` for one it does not have. Omission stays the default, +- // which is "run as the image's `AGENTD_DEFAULT_USER`, else as the daemon's own user". ++ // which is "run as the daemon's own user". + .with_user(spec.user) + .with_group(spec.group) + // No `with_timeout_sec`: the client-side deadline is the caller's `--timeout`, applied +diff --git a/crates/protocol/src/exec.rs b/crates/protocol/src/exec.rs +index b572818..bba0754 100644 +--- a/crates/protocol/src/exec.rs ++++ b/crates/protocol/src/exec.rs +@@ -232,9 +232,8 @@ pub struct StartRequest { + #[serde(default)] + pub env: HashMap, + /// The user to demote to: a numeric uid, or a name resolved against the guest's +- /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as +- /// that string here would be, or the daemon's own user when the image sets none. A name +- /// the guest does not have answers 400 `unknown_user` before anything is spawned. ++ /// `/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the ++ /// guest does not have answers 400 `unknown_user` before anything is spawned. + /// + /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME` + /// from the row, beneath the launch environment and `env`. A user given by name also +@@ -551,8 +550,7 @@ impl StartRequest { + self + } + +- /// The user to demote to; `None` runs as the image's `AGENTD_DEFAULT_USER`, or as the +- /// daemon's own user when the image sets none. ++ /// The user to demote to; `None` runs as the daemon's own user. + pub fn with_user(mut self, user: Option) -> Self { + self.user = user; + self diff --git a/verify/spec/agentd.symspec.json b/verify/spec/agentd.symspec.json index 003d858e..b2ef40e4 100644 --- a/verify/spec/agentd.symspec.json +++ b/verify/spec/agentd.symspec.json @@ -143,6 +143,25 @@ "verificationMethod": "test", "verifies": [] }, + "4e5a190c-e8f7-42c8-a839-556390df52ef": { + "createdAt": "2026-10-12T16:35:15.580Z", + "derives": [], + "id": "4e5a190c-e8f7-42c8-a839-556390df52ef", + "key": "AGENTD-23", + "negated": false, + "patternType": "optional-feature", + "preCondition": "the image environment sets the default user", + "priority": "critical", + "refines": [], + "satisfies": [], + "sentence": "Where the image environment sets the default user, the agentd shall demote the child of a start request that names its user to the named user rather than the default user.", + "status": "approved", + "systemName": "agentd", + "systemResponse": "demote the child of a start request that names its user to the named user rather than the default user", + "updatedAt": "2026-10-12T16:35:15.580Z", + "verificationMethod": "test", + "verifies": [] + }, "52611cba-ebc5-4e49-836c-b50fcf4227cf": { "createdAt": "2026-09-24T21:09:26.751Z", "derives": [], @@ -218,6 +237,25 @@ "verificationMethod": "test", "verifies": [] }, + "63daa046-6a0e-4c15-9f1a-2f830345d8e3": { + "createdAt": "2026-10-12T16:35:16.173Z", + "derives": [], + "id": "63daa046-6a0e-4c15-9f1a-2f830345d8e3", + "key": "AGENTD-24", + "negated": false, + "patternType": "unwanted-behavior", + "priority": "critical", + "refines": [], + "satisfies": [], + "sentence": "If a start request names no user and the guest's /etc/passwd does not contain the default user, then the agentd shall answer Bad Request with the unknown_user error naming the default user setting and spawn no child.", + "status": "approved", + "systemName": "agentd", + "systemResponse": "answer Bad Request with the unknown_user error naming the default user setting and spawn no child", + "trigger": "a start request names no user and the guest's /etc/passwd does not contain the default user", + "updatedAt": "2026-10-12T16:35:16.173Z", + "verificationMethod": "test", + "verifies": [] + }, "64c3f6b9-b3e1-459f-95db-969638de56e6": { "createdAt": "2026-08-05T17:40:49.854Z", "derives": [], @@ -350,6 +388,25 @@ "verificationMethod": "test", "verifies": [] }, + "d9e700f3-1ff4-4f97-975a-4c62102edd67": { + "createdAt": "2026-10-12T16:35:15.039Z", + "derives": [], + "id": "d9e700f3-1ff4-4f97-975a-4c62102edd67", + "key": "AGENTD-22", + "negated": false, + "patternType": "optional-feature", + "preCondition": "the image environment sets the default user", + "priority": "high", + "refines": [], + "satisfies": [], + "sentence": "Where the image environment sets the default user, the agentd shall resolve the default user against the guest's /etc/passwd as the user of each start request that names no user.", + "status": "approved", + "systemName": "agentd", + "systemResponse": "resolve the default user against the guest's /etc/passwd as the user of each start request that names no user", + "updatedAt": "2026-10-12T16:35:15.039Z", + "verificationMethod": "test", + "verifies": [] + }, "db931bc9-7cf8-473d-8db1-59409b935ce2": { "createdAt": "2026-09-30T12:00:00.000Z", "derives": [], diff --git a/verify/spec/traced/AGENTD.toml b/verify/spec/traced/AGENTD.toml index 0be41729..7c9bd9b7 100644 --- a/verify/spec/traced/AGENTD.toml +++ b/verify/spec/traced/AGENTD.toml @@ -61,6 +61,21 @@ issue = "#226" [AGENTD-16] issue = "#224" +# The default user, AGENTD-22 to AGENTD-24, is read from the image's environment at daemon +# start, so a live check needs an image of its own that sets it: the live suite's images set +# none, and setting one there would change who every other live exec runs as. +[AGENTD-22] +issue = "#595" +waive.live = "the live suite's images set no AGENTD_DEFAULT_USER, and one that did would change who every other live exec runs as; the handler tests and the Gherkin tier spawn real children under a configured default" + +[AGENTD-23] +issue = "#595" +waive.live = "the live suite's images set no AGENTD_DEFAULT_USER, and one that did would change who every other live exec runs as; the handler tests and the Gherkin tier spawn real children under a configured default" + +[AGENTD-24] +issue = "#595" +waive.live = "the live suite's images set no AGENTD_DEFAULT_USER, and one that did would change who every other live exec runs as; the handler tests and the Gherkin tier refuse a start under a default the guest does not have" + # The tunnel's keys, AGENTD-17 to AGENTD-21 here and BIND-21, BIND-23 and BIND-24 in BIND.toml, # share their gherkin waiver, so a change to one is a change to all of them. crates/model/src/ # tunnel.rs carries their model layer, and the tunnel_fuzz harnesses in agentd and the edges From a23b15bf372d28a9ee7805c0b6eeb06f0a413ffa Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Mon, 12 Oct 2026 18:23:43 +0000 Subject: [PATCH 2/5] ci: build the parity tunnel fixture before the mutants job's baseline The CLI's `guards::parity::the_cli_answers_the_shared_case_corpus_against_its_fakes` spawns microvms-core's `parity-tunnel-peer` example from the test binary's target directory. A mutant's `cargo test -p microvms-cli` never builds another package's example, so a change with mutants in microvms-cli failed cargo-mutants' unmutated baseline (exit 4, "build the fixture with mise run parity:fixtures"), as shard 3 of 4 did on #600. `ci:mutants` now depends on `parity:fixtures`, which builds the example into the target the `--in-place` run tests from. Shard 3 of 4 run locally with this: baseline ok in 31 s build + 9 s test, 1 mutant, unviable. --- .config/mise/tasks/ci.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.config/mise/tasks/ci.toml b/.config/mise/tasks/ci.toml index a29693fa..d1cca0f0 100644 --- a/.config/mise/tasks/ci.toml +++ b/.config/mise/tasks/ci.toml @@ -161,6 +161,10 @@ description = "CI's `mutants` job's command: the wrapper over cargo-mutants. The # `--in-place` mutates this checkout, which the runner throws away, so rust-cache can restore # dependencies into its target. Run from a worktree you keep, pass it without `--in-place`, or # run `mise run mutants`. +# The CLI's parity test spawns microvms-core's `parity-tunnel-peer` example from the test +# binary's target directory, and a mutant's `cargo test -p microvms-cli` never builds another +# package's example, so the fixture is built first into the target `--in-place` tests use. +depends = ["parity:fixtures"] tools = { "cargo:cargo-mutants" = "{{vars.cargo_mutants}}" } run = "./tools/check-mutants.py" From d6a33c9f6cfd66a9adccc47a46ecdc0b953dab95 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Mon, 12 Oct 2026 21:53:17 +0000 Subject: [PATCH 3/5] fix(agentd): resolve the default user to a passwd row and give it uploads (#595) A numeric AGENTD_DEFAULT_USER was resolved the way a request's own digits are: a bare uid in the daemon's group. AGENTD_DEFAULT_USER=1000 ran workloads as uid 1000 in gid 0, so they could read and write whatever root's group can. The default now resolves to a row of the guest's /etc/passwd, by name or by the uid its digits spell. The child gets that row's uid, primary gid, HOME, USER and LOGNAME. A default with no row, digits included, refuses each start without a `user` as 400 unknown_user. The detail names the variable and says the image needs a passwd row (AGENTD-24). A request's own digits still run as a bare uid in the daemon's group (AGENTD-16). The model gains numbered and unnumbered defaults, and the fuzz harness, the BDD tier and the handler tests cover both. Uploads stayed root's, so a nameless exec under the default user couldn't write the tree `run . --exec` and `cp` had just uploaded. With the variable set, what PUT /v1/fs/tar and PUT /v1/fs/file create (files, directories, symlinks, and created parents) now belongs to the default user's uid and primary gid. What already existed keeps its owner, and a replaced file or symlink keeps the owner of the entry it replaced. Each chown is fchownat(fd, "", AT_EMPTY_PATH) on the entry's own descriptor, never a path: tar members through the extraction's openat2 walk, and a symlink opened as itself and checked to be a symlink first. A default with no row refuses the upload with the same 400 unknown_user before the body is read. Unset, uploads stay root's. The fs API has no directory-creation route, so these two routes are the whole surface. The schema lists the new 400 on both. The changelog says an image that sets the variable needs a client at this release or later, since an older one sends the recipe's chown and a sync's deletions with no `user`. fs.rs's directory and link open flags become `.union()` consts that every open shares, so cargo-mutants has no equivalent `|` mutant to report. The harden-agentd and default-user FAIL_TO_PASS patches are rebased onto the new fs.rs, and new seeded faults in verify/guards/faults/default-user.toml cover each rule above. --- changelog.d/595.added.md | 21 +- crates/agentd/Cargo.toml | 7 +- crates/agentd/src/exec.rs | 32 + crates/agentd/src/exec_start.rs | 147 +++- crates/agentd/src/exec_start_fuzz.rs | 69 +- crates/agentd/src/fs.rs | 674 +++++++++++++-- crates/agentd/src/schema.rs | 30 +- crates/agentd/tests/bdd_exec_start.rs | 7 + .../agentd/tests/features/exec_start.feature | 19 + crates/microvms-app/src/agents/mod.rs | 7 +- crates/microvms-app/src/workspace.rs | 4 +- crates/model/src/exec_start.rs | 146 +++- crates/protocol/src/exec.rs | 7 +- docs/PROTOCOL.md | 42 +- docs/TRACEABILITY.md | 2 +- docs/TRUST.md | 14 +- docs/schema.json | 18 +- .../operations/write-a-guest-dockerfile.md | 4 +- site/authored/learn/tutorial/long-lived-vm.md | 2 +- verify/guards/faults/default-user.toml | 115 ++- ...eletion-path-is-refused-not-executed.patch | 779 +++++++++++++++++- ...nk-preserves-its-targets-permissions.patch | 35 +- ...ections-past-the-capacity-are-closed.patch | 35 +- ...le-keep-alive-connections-are-closed.patch | 35 +- ...p-incomplete-headers-have-a-deadline.patch | 35 +- ...l-access-uploads-then-chowns-as-root.patch | 779 +++++++++++++++++- ...nd-surviving-group-members-share-the.patch | 35 +- ...inishes-when-a-detached-writer-keeps.patch | 35 +- ...-drain-has-a-deadline-and-closes-the.patch | 35 +- ...-file-preserves-its-other-hard-links.patch | 35 +- ...-hook-body-collection-has-a-deadline.patch | 35 +- ...envelopes-are-bounded-before-parsing.patch | 35 +- 32 files changed, 2868 insertions(+), 407 deletions(-) diff --git a/changelog.d/595.added.md b/changelog.d/595.added.md index 1eb11d80..d07ff6bf 100644 --- a/changelog.d/595.added.md +++ b/changelog.d/595.added.md @@ -1,10 +1,17 @@ - **An image can set the user an exec without `user` runs as (#595).** The daemon reads - `AGENTD_DEFAULT_USER` from the image's environment at startup: a user name, or digits read as - a uid. A start request that omits `user`, from `exec` or `run --exec` alike, resolves it at - each start exactly as it would resolve that string in `user`, so a user an earlier exec created - resolves on the next. A request's own `user` still runs as given, `root` and `0` included. If - the guest's `/etc/passwd` lacks the value, each start without a `user` answers - `400 unknown_user`, and the detail mentions the variable. With it unset, nothing changes. + `AGENTD_DEFAULT_USER` from the image's environment at startup: a user name, or a uid as + digits. A start request that omits `user`, from `exec` or `run --exec` alike, resolves it at + each start to a row of the guest's `/etc/passwd`, by name or by uid, so a user an earlier exec + created resolves on the next. The child gets that row's uid, primary gid, `HOME`, `USER` and + `LOGNAME`, a uid included. A request's own `user` still runs as given, `root` and `0` + included. If no row matches, digits included, each start without a `user` answers + `400 unknown_user`, and the detail mentions the variable. Uploads follow the same row: what + `PUT /v1/fs/tar` and `PUT /v1/fs/file` create, parent directories included, belongs to the + default user, so a nameless exec can write the tree `run . --exec` and `cp` upload, and what + already existed keeps its owner. A default with no row refuses an upload with the same + `400 unknown_user` before anything is written. With the variable unset, nothing changes. Workload hook handlers still run as root. The agent recipe's `chown` of `/workspace` and a sync's in-guest deletions now ask for uid 0 by number, so an image's default user can't make - them fail. + them fail. An image that sets the variable needs a client at this release or later: an older + client sends that `chown` and those deletions with no `user`, so they run as the default user + and fail. diff --git a/crates/agentd/Cargo.toml b/crates/agentd/Cargo.toml index 9d15d2c0..ecb39369 100644 --- a/crates/agentd/Cargo.toml +++ b/crates/agentd/Cargo.toml @@ -56,10 +56,11 @@ tar = "0.4.46" subtle = "2.6" # Features, each earning its place: `signal` for the exec kill path, `fs` for # `statvfs` (the disk-pressure guard), `mount` for the `MS_BIND` that shadows the -# read-only procfs `boot_id`, and `hostname` for `sethostname`. Pinned to the 0.31 -# line, which is what is already locked; nix is pre-1.0 and every minor is a +# read-only procfs `boot_id`, `hostname` for `sethostname`, and `user` for the +# `fchownat` that gives an upload's new entries the image's default user. Pinned to +# the 0.31 line, which is what is already locked; nix is pre-1.0 and every minor is a # breaking change, so a caret on 0.31 is the widest safe range. -nix = { version = "0.31", features = ["signal", "fs", "mount", "hostname"] } +nix = { version = "0.31", features = ["signal", "fs", "mount", "hostname", "user"] } tempfile = "3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } diff --git a/crates/agentd/src/exec.rs b/crates/agentd/src/exec.rs index 9b9fc01d..c6bd7f9f 100644 --- a/crates/agentd/src/exec.rs +++ b/crates/agentd/src/exec.rs @@ -3132,6 +3132,38 @@ mod tests { ); } + /// **AGENTD-22, AGENTD-24.** Through the real handler, a default spelled as digits runs as + /// the row of that uid, with its identity, and digits no row carries are refused naming + /// the variable and the missing row, where a request's own digits would run. + #[tokio::test] + async fn a_numeric_default_runs_as_its_row_and_one_with_no_row_is_refused() { + let probe = "echo \"$(id -u) $(id -g) $HOME $USER\""; + let uid = own_id("-u").to_string(); + let (state, _dir) = guest_with_default(&[], &[], Some(&uid)); + let mut request = req("default-digits", &[probe]); + request.shell = true.into(); + let outcome = started(&state, request).await; + assert_eq!( + outcome.stdout.trim(), + format!("{} {} /home/tester tester", own_id("-u"), own_id("-g")) + ); + + let (state, dir) = guest_with_default(&[], &[], Some("4242424")); + let marker = dir.path().join("ran"); + let mut request = req( + "default-digits-no-row", + &[&format!("touch {}", marker.display())], + ); + request.shell = true.into(); + let detail = refused(&state, request, "unknown_user", &marker).await; + assert!( + detail.contains("\"4242424\"") + && detail.contains("AGENTD_DEFAULT_USER") + && detail.contains("needs a passwd row"), + "{detail}" + ); + } + /// **AGENTD-23.** A user the start names wins over the default. The default here is an /// account this non-root test cannot become (uid 65534), so a daemon that applied it /// would fail the spawn; naming `tester` runs as `tester`, by name and by uid. diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs index 1dfcce02..453b4f54 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs @@ -23,10 +23,13 @@ //! //! It runs as the image's default user when the image sets `AGENTD_DEFAULT_USER`, and as the //! daemon's own user (root) when it doesn't (AGENTD-22). The default is resolved here, per -//! request, exactly as a request's `user` string would be, and a default that names nobody -//! is refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's +//! request, to a passwd row: the row its name matches, or the row of the uid its digits +//! spell. Either way the child takes the row's uid, primary gid and identity, so a numeric +//! default doesn't keep the daemon's gid 0. A default with no row, digits included, is +//! refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's //! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default -//! decides what a caller gets by not saying, not what a caller may ask for. +//! decides what a caller gets by not saying, not what a caller may ask for. The same row +//! owns what an upload creates (`fs.rs`). //! //! # The environment is five layers, lowest first //! @@ -214,8 +217,10 @@ impl Plan { struct User<'a> { uid: u32, row: Option<&'a PasswdRow>, - /// Given as a name that matched a row, as opposed to a number. - named: bool, + /// Takes the row's primary gid when the request names no group: a name the request + /// gave that matched a row, or the image's default user. A uid the request gave, as an + /// integer or as digits, keeps the daemon's group (AGENTD-16). + primary_gid: bool, } /// All ASCII digits that fit a `u32`: a string read as an id when it names no row. @@ -234,21 +239,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, NameOrId::Id(uid) => Ok(User { uid: *uid, row: by_uid(*uid), - named: false, + primary_gid: false, }), NameOrId::Name(name) => { if let Some(row) = rows.iter().find(|row| row.name == *name) { return Ok(User { uid: row.uid, row: Some(row), - named: true, + primary_gid: true, }); } match digits(name) { Some(uid) => Ok(User { uid, row: by_uid(uid), - named: false, + primary_gid: false, }), None => Err(Rejection::new( ERROR_UNKNOWN_USER, @@ -259,13 +264,34 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, } } +/// The image's default user as its passwd row (AGENTD-22): the row its name matches, or the +/// row of the uid its digits spell. +/// +/// Stricter than a request's `user`, where digits with no row run as a bare uid. The default +/// runs every start that names no user and owns every entry an upload creates, and a uid +/// with no row has no primary gid to give either, so the child would keep the daemon's gid +/// 0. A default with no row is refused instead (AGENTD-24). The error is the clause a +/// refusal's detail ends with, naming the value and the variable that set it, because the +/// caller sent no name to look for and the fix is in the image. +pub fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { + match resolve_user(&NameOrId::Name(name.to_string()), rows) { + Ok(User { row: Some(row), .. }) => Ok(row), + Ok(User { row: None, .. }) => Err(format!( + "the default user {name:?} that {DEFAULT_USER_VAR} sets is a uid with no row in \ + the guest's /etc/passwd (the image needs a passwd row for it)" + )), + Err(_) => Err(format!( + "the default user {name:?} that {DEFAULT_USER_VAR} sets is not in the guest's \ + /etc/passwd" + )), + } +} + /// The user a request resolves to: its own `user` when it names one (AGENTD-23), else the -/// image's default resolved as that string would be (AGENTD-22), else none, which is the -/// daemon's own user. +/// image's default user's row (AGENTD-22), else none, which is the daemon's own user. /// -/// A default that names nobody is `unknown_user` like a request's unknown name (AGENTD-24), -/// with a detail that says the request named no user and which variable set the one that -/// failed, because the caller sent no name to look for and the fix is in the image. +/// A default with no row is `unknown_user` like a request's unknown name (AGENTD-24), with +/// a detail that says the request named no user and which variable set the one that failed. fn resolve_request_user<'a>( request: &StartRequest, default_user: Option<&str>, @@ -273,17 +299,17 @@ fn resolve_request_user<'a>( ) -> Result>, Rejection> { match (&request.user, default_user) { (Some(spec), _) => resolve_user(spec, rows).map(Some), - (None, Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows) - .map(Some) - .map_err(|_| { - Rejection::new( - ERROR_UNKNOWN_USER, - format!( - "the request names no user, and the default user {name:?} that \ - {DEFAULT_USER_VAR} sets is not in the guest's /etc/passwd" - ), - ) - }), + (None, Some(name)) => match default_user_row(name, rows) { + Ok(row) => Ok(Some(User { + uid: row.uid, + row: Some(row), + primary_gid: true, + })), + Err(why) => Err(Rejection::new( + ERROR_UNKNOWN_USER, + format!("the request names no user, and {why}"), + )), + }, (None, None) => Ok(None), } } @@ -376,11 +402,12 @@ pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result Some(resolve_group(spec, &groups)?), - // A named user takes its row's primary group, the way `su` and `docker -u name` do. - // A numeric uid keeps the daemon's group, which is what protocol 1 did (AGENTD-16). + // A named user takes its row's primary group, the way `su` and `docker -u name` do, + // and so does the default user, however the image spelled it (AGENTD-22). A uid the + // request gives keeps the daemon's group, which is what protocol 1 did (AGENTD-16). None => user .as_ref() - .filter(|user| user.named) + .filter(|user| user.primary_gid) .and_then(|user| user.row) .map(|row| row.gid), }; @@ -765,10 +792,10 @@ mod tests { ); } - /// **AGENTD-22.** A request that names no user resolves the image's default exactly as - /// it would resolve that string: a name to its row's uid, primary gid and identity, - /// digits that name no row to that uid with the daemon's group, and a named group still - /// overrides the primary. With no default it runs as the daemon, as it always did. + /// **AGENTD-22.** A request that names no user resolves the image's default to a passwd + /// row: a name to its row, digits to the row of that uid, each with the row's uid, + /// primary gid and identity, and a named group still overrides the primary. With no + /// default it runs as the daemon, as it always did. #[test] fn a_request_that_names_no_user_resolves_the_default_as_its_user() { let nameless = request(None, None, false.into()); @@ -798,16 +825,23 @@ mod tests { "the default resolves exactly as the same name in the request" ); - let plan = resolve_with_default(&nameless, Some("4242")).expect("resolves"); - assert_eq!((plan.uid, plan.gid), (Some(4242), None)); - assert!(plan.environment().is_empty()); + // Digits are the row of that uid, with its primary gid: a request's "1000" keeps the + // daemon's group, and a default's doesn't, or the child would keep gid 0. let plan = resolve_with_default(&nameless, Some("1000")).expect("resolves"); - assert_eq!((plan.uid, plan.gid), (Some(1000), None)); - assert_eq!(plan.environment()["USER"], "agent"); + assert_eq!( + plan, + resolve_with_default(&nameless, Some("agent")).expect("resolves"), + "a uid the default spells resolves as its row's name" + ); + let plan = resolve_with_default(&nameless, Some("0")).expect("resolves"); + assert_eq!((plan.uid, plan.gid), (Some(0), Some(0))); + assert_eq!(plan.environment()["HOME"], "/root"); let grouped = request(None, Some("staff".into()), false.into()); let plan = resolve_with_default(&grouped, Some("agent")).expect("resolves"); assert_eq!((plan.uid, plan.gid), (Some(1000), Some(50))); + let plan = resolve_with_default(&grouped, Some("1002")).expect("resolves"); + assert_eq!((plan.uid, plan.gid), (Some(1002), Some(50))); } /// **AGENTD-23.** A user the request names wins over the default, `root` and `0` @@ -853,12 +887,13 @@ mod tests { ); } - /// **AGENTD-24.** A default that names nobody, empty included, refuses a request that - /// names no user as `unknown_user`, with a detail naming the value and the variable, - /// and it is resolved first, ahead of the group and the shell. + /// **AGENTD-24.** A default with no passwd row, empty and all-digit included, refuses a + /// request that names no user as `unknown_user`, with a detail naming the value and the + /// variable, and it is resolved first, ahead of the group and the shell. Digits with no + /// row say the image needs one, since a request's same digits would run. #[test] fn a_default_that_names_nobody_is_refused_naming_the_variable() { - for default in ["ghost", "", "12a"] { + for default in ["ghost", "", "12a", "4242"] { let refused = resolve_with_default(&request(None, None, false.into()), Some(default)) .expect_err(default); assert_eq!(refused.error, ERROR_UNKNOWN_USER, "{default:?}"); @@ -868,7 +903,22 @@ mod tests { "{}", refused.detail ); + assert_eq!( + refused.detail.contains("needs a passwd row"), + default == "4242", + "{}", + refused.detail + ); } + assert_eq!( + resolve_with_default( + &request(Some("4242".into()), None, false.into()), + Some("4242") + ) + .expect("a request's own digits run with no row") + .uid, + Some(4242) + ); let all_bad = request(None, Some("ghosts".into()), "fish".into()); assert_eq!( resolve_with_default(&all_bad, Some("ghost")) @@ -878,6 +928,23 @@ mod tests { ); } + /// **AGENTD-22, AGENTD-24.** The default's row is the one a name matches before the one a + /// uid matches, so a user named with digits is that user, and a miss is the clause a + /// refusal ends with. + #[test] + fn the_default_user_row_prefers_a_name_to_a_uid() { + let rows = + parse_passwd("1000:x:1500:1500::/home/digits:/bin/sh\nagent:x:1000:1001::/a:/bin/sh\n"); + assert_eq!(default_user_row("1000", &rows).map(|row| row.uid), Ok(1500)); + assert_eq!(default_user_row("1500", &rows).map(|row| row.gid), Ok(1500)); + assert_eq!( + default_user_row("agent", &rows).map(|row| row.gid), + Ok(1001) + ); + let missing = default_user_row("77", &rows).expect_err("no row"); + assert!(missing.starts_with("the default user \"77\""), "{missing}"); + } + /// **AGENTD-12.** The snapshot drops every `AGENTD_` variable and anything not UTF-8. #[test] fn the_snapshot_drops_agentd_configuration_and_non_utf8() { diff --git a/crates/agentd/src/exec_start_fuzz.rs b/crates/agentd/src/exec_start_fuzz.rs index e4996960..eb3c1e89 100644 --- a/crates/agentd/src/exec_start_fuzz.rs +++ b/crates/agentd/src/exec_start_fuzz.rs @@ -26,9 +26,10 @@ //! (AGENTD-11); no `AGENTD_*` image key and no token reaches the plan (AGENTD-12); the snapshot's //! count is its non-`AGENTD_` keys (AGENTD-13); a named shell runs as the file it resolved to //! (AGENTD-14); integers and booleans mean what they did (AGENTD-16); a request that names no -//! user resolves the default as it would that string (AGENTD-22), a request's own user is -//! never replaced by it (AGENTD-23), and a default that names nobody is refused naming the -//! variable (AGENTD-24). A second check feeds raw +//! user runs as the default's passwd row, found by name or by the uid its digits spell, with +//! that row's primary gid (AGENTD-22), a request's own user is never replaced by it +//! (AGENTD-23), and a default with no row is refused naming the variable (AGENTD-24). A +//! second check feeds raw //! bytes to the `StartRequest` deserializer, which must refuse or accept but never panic. use std::path::Path; @@ -243,16 +244,17 @@ fn start_resolution() { ); // What the resolution must decide, derived independently of it. The user is the - // request's own when it names one, whatever the default (AGENTD-23), and the default - // read as a name otherwise (AGENTD-22). - let user = request - .user - .clone() - .or_else(|| default_user.map(|name| NameOrId::Name(name.to_string()))); + // request's own when it names one, whatever the default (AGENTD-23), and otherwise the + // default's row: the one its name matches, else the one with the uid its digits spell + // (AGENTD-22). A default with neither is refused (AGENTD-24). let user_row = |name: &str| rows.iter().find(|row| row.name == name); - let user_ok = match &user { - Some(NameOrId::Name(name)) => user_row(name).is_some() || digits(name).is_some(), - _ => true, + let uid_row = |uid: u32| rows.iter().find(|row| row.uid == uid); + let default_row = + default_user.map(|name| user_row(name).or_else(|| digits(name).and_then(uid_row))); + let user_ok = match (&request.user, default_row) { + (Some(NameOrId::Name(name)), _) => user_row(name).is_some() || digits(name).is_some(), + (Some(NameOrId::Id(_)), _) | (None, None) => true, + (None, Some(row)) => row.is_some(), }; let group_ok = match &request.group { Some(NameOrId::Name(name)) => { @@ -274,13 +276,16 @@ fn start_resolution() { ERROR_UNKNOWN_SHELL }; assert_eq!(rejection.error, expected, "{rejection:?} for {body}"); - // AGENTD-24: a refused default names itself and the variable; a refused name - // the request gave is about that name alone. + // AGENTD-24: a refused default names itself and the variable, and says the + // image needs a row when its digits are a uid; a refused name the request + // gave is about that name alone. if expected == ERROR_UNKNOWN_USER { match (&request.user, default_user) { (None, Some(name)) => assert!( rejection.detail.contains(&format!("{name:?}")) - && rejection.detail.contains(DEFAULT_USER_VAR), + && rejection.detail.contains(DEFAULT_USER_VAR) + && (digits(name).is_none() + || rejection.detail.contains("needs a passwd row")), "{rejection:?}" ), _ => assert!( @@ -302,27 +307,35 @@ fn start_resolution() { ); let env = plan.environment(); - // AGENTD-7 and AGENTD-16: the uid is the row's for a matching name, the number - // otherwise; the gid is the group's, a named user's primary, or unchanged. - let row = match &user { - Some(NameOrId::Name(name)) => user_row(name).or_else(|| { - digits(name).and_then(|uid| rows.iter().find(|row| row.uid == uid)) - }), - Some(NameOrId::Id(uid)) => rows.iter().find(|row| row.uid == *uid), - None => None, + // AGENTD-7, AGENTD-16 and AGENTD-22: the uid is the row's for a matching name + // or a default, the number otherwise; the gid is the group's, a named user's or + // the default's primary, or unchanged. + let row = match &request.user { + Some(NameOrId::Name(name)) => { + user_row(name).or_else(|| digits(name).and_then(uid_row)) + } + Some(NameOrId::Id(uid)) => uid_row(*uid), + None => default_row.flatten(), }; - match &user { + match &request.user { Some(NameOrId::Name(name)) => match user_row(name) { Some(named) => assert_eq!(plan.uid, Some(named.uid)), None => assert_eq!(plan.uid, digits(name)), }, Some(NameOrId::Id(uid)) => assert_eq!(plan.uid, Some(*uid)), - None => assert_eq!(plan.uid, None), + None => assert_eq!(plan.uid, row.map(|row| row.uid), "{body}"), } if request.group.is_none() { - let named = - matches!(&user, Some(NameOrId::Name(name)) if user_row(name).is_some()); - let expected = if named { row.map(|row| row.gid) } else { None }; + let primary = match &request.user { + Some(NameOrId::Name(name)) => user_row(name).is_some(), + Some(NameOrId::Id(_)) => false, + None => default_user.is_some(), + }; + let expected = if primary { + row.map(|row| row.gid) + } else { + None + }; assert_eq!(plan.gid, expected, "{body}"); } diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs index c442ed62..6844bcb7 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs @@ -65,7 +65,25 @@ //! resolution that would leave the root stops with `EXDEV`. Both become a 400 that //! names the member. The daemon no longer has to reason about whether a redirect is //! safe, because a redirected write cannot happen. +//! +//! # Who owns what an upload creates +//! +//! The daemon writes as root, so without `AGENTD_DEFAULT_USER` every entry an upload creates +//! is root's, as it always was. With it set, each entry `PUT /v1/fs/tar` or +//! `PUT /v1/fs/file` creates, parent directories included, is owned by the default user's +//! uid and primary gid, resolved by the rule a start that names no user uses +//! (`exec_start::default_user_row`), so the user a nameless exec runs as can write the tree +//! an upload made for it. An entry that was already there keeps its owner: an existing +//! directory, a file `PUT /v1/fs/file` truncates, and a name the archive replaces, whose new +//! entry takes the old one's owner. A hard link is a name for an inode that already exists, +//! so it changes no owner. A default with no passwd row refuses the upload with +//! `400 unknown_user` before anything is written. +//! +//! Each chown goes through a descriptor of the entry itself, never a path: the open file, +//! or a directory or symlink opened in its confined parent without following a symlink. A +//! name swapped between the create and the chown can't move the chown onto another file. +use std::borrow::Cow; use std::collections::HashMap; use std::ffi::{OsStr, OsString}; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -75,19 +93,22 @@ use std::path::{Component, Path, PathBuf}; use nix::errno::Errno; use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, OpenHow, ResolveFlag, openat2}; -use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; -use nix::unistd::{UnlinkatFlags, linkat, symlinkat, unlinkat}; +use nix::sys::stat::{FchmodatFlags, Mode, SFlag, fchmodat, fstat, fstatat, mkdirat}; +use nix::unistd::{Gid, Uid, UnlinkatFlags, fchownat, linkat, symlinkat, unlinkat}; +use axum::Json; use axum::body::Body; use axum::extract::{Request, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures_util::TryStreamExt; +use protocol::exec::{ERROR_UNKNOWN_USER, ErrorBody}; use tar::EntryType; use tokio_util::io::{ReaderStream, StreamReader}; use crate::disk::{self, CopyError}; +use crate::exec_start::{default_user_row, parse_passwd, read_database}; use crate::state::AppState; /// The two typed shapes in this module, re-exported from their original paths. The @@ -123,6 +144,116 @@ fn insufficient_storage(path: &Path, reading: disk::Reading) -> Response { .into_response() } +/// The owner of an entry an upload creates: the image's default user's uid and primary gid. +/// `None` in place of one, throughout this module, leaves an entry the daemon's, which is +/// root. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Owner { + uid: u32, + gid: u32, +} + +impl Owner { + /// Gives the entry `fd` refers to this owner. `fd` may be `O_PATH`: with an empty path + /// and `AT_EMPTY_PATH`, `fchownat` changes the descriptor's own inode, a symlink's + /// included. + fn apply(self, fd: BorrowedFd<'_>) -> Result<(), Errno> { + fchownat( + fd, + "", + Some(Uid::from_raw(self.uid)), + Some(Gid::from_raw(self.gid)), + AtFlags::AT_EMPTY_PATH, + ) + } +} + +/// The owner of what this upload creates, or the detail of the `400 unknown_user` that +/// refuses it. +/// +/// Resolved at each upload from the guest's `/etc/passwd`, the way a start resolves the +/// default, so a user an earlier exec created owns the next upload. A default with no row +/// is refused here, before the body is read, because extracting it as root would leave +/// the default user a tree it can't write. The `Err` is the detail rather than a +/// `Response` so the variant stays small (clippy's `result_large_err`); +/// [`unknown_default_user`] builds the answer. +fn upload_owner(state: &AppState) -> Result, String> { + let config = state.config(); + let Some(name) = config.default_user.as_deref() else { + return Ok(None); + }; + let rows = parse_passwd(&read_database(&config.passwd_path)); + match default_user_row(name, &rows) { + Ok(row) => Ok(Some(Owner { + uid: row.uid, + gid: row.gid, + })), + Err(why) => { + let detail = format!("an upload's new entries belong to the default user, and {why}"); + Err(detail) + } + } +} + +/// The `400 unknown_user` that refuses an upload under a default with no passwd row. The +/// body is the exec routes' JSON `ErrorBody`, since the slug is theirs, where this module's +/// other refusals are plain text. +fn unknown_default_user(detail: String) -> Response { + tracing::warn!(detail, "upload refused"); + ( + StatusCode::BAD_REQUEST, + Json(ErrorBody { + error: Cow::Borrowed(ERROR_UNKNOWN_USER), + detail, + }), + ) + .into_response() +} + +/// Opens the directory `path`, creating each missing component as `create_dir_all` would, +/// and gives each component it creates to `owner`. +/// +/// Walked a component at a time from `/`, or from the working directory for a relative +/// path, each step relative to the descriptor of the last. A component that already exists +/// is followed wherever it leads, symlinks included, as `create_dir_all` follows them, +/// because the path is the caller's. A component this walk creates is opened with +/// [`Confined::RESOLVE`] and chowned through that descriptor, so a symlink put in its +/// place after the `mkdirat` stops the walk instead of taking the chown. +fn open_dirs_owned(path: &Path, owner: Owner) -> io::Result { + let open = |dir: BorrowedFd<'_>, name: &OsStr, resolve: ResolveFlag| { + let how = OpenHow::new().flags(Confined::DIRECTORY).resolve(resolve); + openat2(dir, name, how) + }; + let start = if path.is_absolute() { "/" } else { "." }; + let mut current = open( + AT_FDCWD, + OsStr::new(start), + ResolveFlag::RESOLVE_NO_MAGICLINKS, + )?; + for component in path.components() { + let name = match component { + Component::Normal(name) => name, + Component::ParentDir => OsStr::new(".."), + Component::RootDir | Component::CurDir | Component::Prefix(_) => continue, + }; + let created = match mkdirat(¤t, name, Mode::from_bits_truncate(0o777)) { + Ok(()) => true, + Err(Errno::EEXIST) => false, + Err(errno) => return Err(errno.into()), + }; + let resolve = if created { + Confined::RESOLVE + } else { + ResolveFlag::RESOLVE_NO_MAGICLINKS + }; + current = open(current.as_fd(), name, resolve)?; + if created { + owner.apply(current.as_fd())?; + } + } + Ok(current) +} + /// Why an archive or member was refused. /// /// The refused member's name travels with the refusal so the 400 can name it. A @@ -296,6 +427,9 @@ fn resolve_member(root: &Path, path: &Path) -> Landing { /// read from or written to even if the descriptor leaks somewhere it should not. struct Confined { root: OwnedFd, + /// Who owns each entry the extraction creates (see the module comment). `None` leaves + /// them the daemon's. + owner: Option, } impl Confined { @@ -329,7 +463,19 @@ impl Confined { .union(ResolveFlag::RESOLVE_NO_SYMLINKS) .union(ResolveFlag::RESOLVE_NO_MAGICLINKS); - /// Opens `root`, creating it if it is absent, and holds it. + /// The open flags of every directory on a walk: a path for `*at` calls only, which + /// fails unless the name is a directory. + const DIRECTORY: OFlag = OFlag::O_CLOEXEC + .union(OFlag::O_PATH) + .union(OFlag::O_DIRECTORY); + + /// The open flags of a symlink opened as itself, for its chown ([`Confined::open_link`]). + const LINK: OFlag = OFlag::O_CLOEXEC + .union(OFlag::O_PATH) + .union(OFlag::O_NOFOLLOW); + + /// Opens `root`, creating it if it is absent, and holds it. With an `owner`, each + /// directory created on the way to `root` is that owner's ([`open_dirs_owned`]). /// /// The root itself is opened by absolute path with only /// `RESOLVE_NO_MAGICLINKS`, not with [`Self::RESOLVE`]. `RESOLVE_BENEATH` @@ -347,13 +493,18 @@ impl Confined { /// and `File::create` this replaced: falling back would mean the confinement a /// caller cannot see silently became the weaker one, and issue #15 is what the /// weaker one allows. - fn open(root: &Path) -> io::Result { - std::fs::create_dir_all(root)?; - let how = OpenHow::new() - .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) - .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); - let root = openat2(AT_FDCWD, root, how).map_err(io::Error::from)?; - Ok(Self { root }) + fn open(root: &Path, owner: Option) -> io::Result { + let root = match owner { + Some(owner) => open_dirs_owned(root, owner)?, + None => { + std::fs::create_dir_all(root)?; + let how = OpenHow::new() + .flags(Self::DIRECTORY) + .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); + openat2(AT_FDCWD, root, how).map_err(io::Error::from)? + } + }; + Ok(Self { root, owner }) } /// Opens the single component `name` under `dir` as a directory. @@ -363,9 +514,7 @@ impl Confined { /// the error name which component failed, and a 400 that names the component /// is the difference between an operator fixing their tree and re-reading it. fn open_dir(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Result { - let how = OpenHow::new() - .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) - .resolve(Self::RESOLVE); + let how = OpenHow::new().flags(Self::DIRECTORY).resolve(Self::RESOLVE); openat2(dir, name, how) } @@ -375,7 +524,8 @@ impl Confined { /// not an error: an archive may name the same directory twice, and a partial /// extraction that is retried has to converge. The open that follows decides /// whether the existing name is usable, and a symlink there fails with - /// `ELOOP` rather than being traversed. + /// `ELOOP` rather than being traversed. A directory this walk creates is given the + /// extraction's owner through that descriptor; one that existed keeps its own. fn make_dirs(&self, parts: &[OsString]) -> Result { let mut current = self.root.try_clone().map_err(|err| WalkError { component: None, @@ -386,17 +536,33 @@ impl Confined { // replayed by `apply_deferred_modes` after every member has landed. // Applying it here is the defect that pass exists to avoid: a 0o500 // directory blocks the writes of its own children. - match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) { - Ok(()) | Err(Errno::EEXIST) => {} + let created = match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) + { + Ok(()) => true, + Err(Errno::EEXIST) => false, Err(errno) => return Err(WalkError::at(parts, index, errno)), - } + }; current = self .open_dir(current.as_fd(), part) .map_err(|errno| WalkError::at(parts, index, errno))?; + if created { + self.own(current.as_fd(), None) + .map_err(|errno| WalkError::at(parts, index, errno))?; + } } Ok(current) } + /// Gives an entry this extraction created its owner: `previous`, the owner of the entry + /// it replaced, when there was one, and the extraction's otherwise. Nothing without an + /// extraction owner, so an unset default leaves every entry the daemon's. + fn own(&self, fd: BorrowedFd<'_>, previous: Option) -> Result<(), Errno> { + match self.owner { + Some(owner) => previous.unwrap_or(owner).apply(fd), + None => Ok(()), + } + } + /// Splits `parts` into the parent directory descriptor and the final name. /// /// Every leaf creation needs exactly this: a `dirfd` the kernel has already @@ -428,9 +594,13 @@ impl Confined { fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; - self.replace(parent.as_fd(), &name); + let previous = self.replace(parent.as_fd(), &name); let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. + self.own(fd.as_fd(), previous) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } @@ -458,9 +628,34 @@ impl Confined { fn create_symlink(&self, parts: &[OsString], target: &Path) -> Result<(), WalkError> { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; - self.replace(parent.as_fd(), &name); + let previous = self.replace(parent.as_fd(), &name); symlinkat(target, parent.as_fd(), name.as_os_str()) - .map_err(|errno| WalkError::at(parts, last, errno)) + .map_err(|errno| WalkError::at(parts, last, errno))?; + if self.owner.is_some() { + let link = Self::open_link(parent.as_fd(), &name) + .map_err(|errno| WalkError::at(parts, last, errno))?; + self.own(link.as_fd(), previous) + .map_err(|errno| WalkError::at(parts, last, errno))?; + } + Ok(()) + } + + /// Opens the symlink `name` under `dir` itself, `O_PATH` and without following it, for + /// its chown. + /// + /// The name is opened rather than chowned in place, and the result has to be a + /// symlink, because a process that can write `dir` can swap the link for a hard link + /// to another file between the `symlinkat` and the chown, and a chown by name would then + /// hand that file to the default user. `EEXIST` says the name no longer holds the link. + fn open_link(dir: BorrowedFd<'_>, name: &OsStr) -> Result { + let how = OpenHow::new().flags(Self::LINK).resolve(Self::RESOLVE); + let link = openat2(dir, name, how)?; + let kind = SFlag::from_bits_truncate(fstat(&link)?.st_mode) & SFlag::S_IFMT; + if kind == SFlag::S_IFLNK { + Ok(link) + } else { + Err(Errno::EEXIST) + } } /// Creates a hard link at `parts` pointing at `source`, an archive-root-relative @@ -479,6 +674,7 @@ impl Confined { ) -> Result<(), HardLinkError> { let (parent, name) = self.parent_of(parts).map_err(HardLinkError::Dest)?; let last = parts.len() - 1; + // No chown: the link names an inode that already exists, whose owner it keeps. self.replace(parent.as_fd(), &name); // An empty source is the archive naming the root as a hard-link target. @@ -524,14 +720,25 @@ impl Confined { Ok(current) } - /// Unlinks `name` under `dir`, ignoring every failure. + /// Unlinks `name` under `dir`, ignoring every failure, and returns the owner of what + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// /// A file or link member replaces the directory entry before creation, so /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Option { + let previous = self + .owner + .and_then(|_| fstatat(dir, name, AtFlags::AT_SYMLINK_NOFOLLOW).ok()) + .map(|stat| Owner { + uid: stat.st_uid, + gid: stat.st_gid, + }); let _ = unlinkat(dir, name, UnlinkatFlags::NoRemoveDir); + previous } /// Applies `mode` to `parts`, without following a symlink at the last component. @@ -646,10 +853,11 @@ pub fn fuzz_extract( probe: disk::available_bytes, reserve: 0, }; - extract_into(root, archive, caps, &guard).map_err(|refusal| format!("{refusal:?}")) + extract_into(root, archive, caps, &guard, None).map_err(|refusal| format!("{refusal:?}")) } -/// Extracts `archive` into `root`, enforcing the data-filter contract. +/// Extracts `archive` into `root`, enforcing the data-filter contract, with each entry it +/// creates given to `owner` when there is one (see the module comment). /// /// Synchronous on purpose: `tar`'s reader is blocking, so callers hand this to /// `spawn_blocking` rather than pretending it is async. @@ -658,17 +866,18 @@ fn extract_into( archive: impl Read, caps: Caps, guard: &disk::Guard, + owner: Option, ) -> Result { // The kernel's half of the confinement, held for the whole extraction. Every // member below is created through this rather than by absolute path, so a // component that turns out to be a symlink stops the write instead of // redirecting it. See the module comment. - let confined = Confined::open(root)?; + let confined = Confined::open(root, owner)?; let mut ar = tar::Archive::new(archive); - // Ownership is dropped for the same reason CPython's `data` filter drops it: - // the archive's uid/gid are the packing host's, meaningless here, and honoring - // them would need privilege we should not spend. + // The archive's ownership is dropped for the same reason CPython's `data` filter + // drops it: its uid/gid are the packing host's, meaningless here, and honoring them + // would need privilege we should not spend. Who owns an entry is `owner`'s to say. ar.set_preserve_permissions(false); ar.set_preserve_ownerships(false); ar.set_unpack_xattrs(false); @@ -1195,7 +1404,8 @@ fn sliced_stream( }) } -/// Writes one file. Not confined to a root; see the module comment. +/// Writes one file. Not confined to a root; see the module comment, which also says who +/// owns the file and the parents this creates. pub async fn write_file(State(state): State, request: Request) -> Response { let query = match fs_query(&request) { Ok(query) => query, @@ -1222,6 +1432,13 @@ pub async fn write_file(State(state): State, request: Request) -> Resp }, }; + // Like the mode, before anything lands: a default user with no passwd row refuses the + // write rather than leaving it a root-owned file. + let owner = match upload_owner(&state) { + Ok(owner) => owner, + Err(detail) => return unknown_default_user(detail), + }; + // Checked before the parent directory is created and before the file is // opened, so a refused write leaves nothing behind — not even an empty file // where the caller's data was supposed to go. An empty file at the target path @@ -1230,24 +1447,61 @@ pub async fn write_file(State(state): State, request: Request) -> Resp return insufficient_storage(&path, reading); } - if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) - && let Err(err) = tokio::fs::create_dir_all(parent).await - { - tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); - return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); - } - - let mut options = tokio::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true); - if let Some(mode) = mode { - // Set at open, so the file never exists with a wider mode than asked for - // even briefly. A separate chmod after writing leaves that window open, - // which matters when the file is a credential. - options.mode(mode); + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + let created = match owner { + None => tokio::fs::create_dir_all(parent).await, + Some(owner) => { + let parent = parent.to_path_buf(); + tokio::task::spawn_blocking(move || open_dirs_owned(&parent, owner).map(drop)) + .await + .unwrap_or_else(|err| Err(io::Error::other(err))) + } + }; + if let Err(err) = created { + tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); + return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); + } } - let mut file = match options.open(&path).await { - Ok(file) => file, + let options = |create_new: bool| { + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).truncate(true); + if create_new { + options.create_new(true); + } else { + options.create(true); + } + if let Some(mode) = mode { + // Set at open, so the file never exists with a wider mode than asked for + // even briefly. A separate chmod after writing leaves that window open, + // which matters when the file is a credential. + options.mode(mode); + } + options + }; + // With an owner to give, `O_EXCL` first, so the daemon knows whether this write + // created the file: one it only truncates keeps its owner. + let opened = match owner { + None => options(false).open(&path).await.map(|file| (file, false)), + // The usual failure is that the file exists, which the plain open truncates; any + // other, the plain open meets again and reports. + Some(_) => match options(true).open(&path).await { + Ok(file) => Ok((file, true)), + Err(_) => options(false).open(&path).await.map(|file| (file, false)), + }, + }; + let mut file = match opened { + Ok((file, created)) => { + if let (true, Some(owner)) = (created, owner) + && let Err(errno) = owner.apply(file.as_fd()) + { + // The file is new and empty, and nobody was told it exists. + let _ = tokio::fs::remove_file(&path).await; + tracing::warn!(path = %path.display(), %errno, "cannot give the file its owner"); + return (StatusCode::INTERNAL_SERVER_ERROR, "cannot set owner").into_response(); + } + file + } Err(err) => { tracing::warn!(path = %path.display(), %err, "cannot open file for write"); return (StatusCode::INTERNAL_SERVER_ERROR, "cannot write file").into_response(); @@ -1464,6 +1718,13 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo .into_response(); } + // Before the body is spooled, so a default user with no passwd row refuses the upload + // without receiving it and before any member lands. + let owner = match upload_owner(&state) { + Ok(owner) => owner, + Err(detail) => return unknown_default_user(detail), + }; + let caps = Caps { max_members: state.config().max_tar_members, max_bytes: state.config().max_tar_bytes, @@ -1494,7 +1755,7 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo }; let extracted = tokio::task::spawn_blocking(move || { - extract_into(&root, spool, caps, &guard).map(|n| (root, n)) + extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n)) }) .await; @@ -1700,7 +1961,7 @@ mod tests { ) -> (TempDir, Result) { let dir = TempDir::new().expect("tempdir"); let root = dir.path().join("root"); - let result = extract_into(&root, archive.as_slice(), caps, &guard); + let result = extract_into(&root, archive.as_slice(), caps, &guard, None); (dir, result) } @@ -1725,7 +1986,7 @@ mod tests { std::fs::hard_link(&original, root.join("member")).expect("hard link"); let archive = Archive::new().file("member", b"replacement bytes").bytes(); assert_eq!( - extract_into(&root, archive.as_slice(), OPEN, &roomy_guard()).unwrap(), + extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), None).unwrap(), 1 ); assert_eq!(std::fs::read(&original).unwrap(), b"original bytes"); @@ -1756,7 +2017,7 @@ mod tests { .link(EntryType::Link, "member", "target") .bytes(); assert_eq!( - extract_into(&root, archive.as_slice(), OPEN, &roomy_guard()).unwrap(), + extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), None).unwrap(), 2 ); assert_eq!( @@ -1995,7 +2256,7 @@ mod tests { // /tmp would leave nothing to find and would read as a pass. let dir = TempDir::new().expect("tempdir"); let root = dir.path().join("nested/deeper/root"); - let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard()); + let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), None); // 400 naming the member, not a 500. The property tier asserts that every // refusal names a member the archive contained, and the member's name here is @@ -2698,7 +2959,7 @@ mod tests { fn a_new_archive_file_refuses_an_occupied_name() { let dir = TempDir::new().expect("tempdir"); std::fs::write(dir.path().join("occupied"), b"original").expect("existing file"); - let confined = Confined::open(dir.path()).expect("root"); + let confined = Confined::open(dir.path(), None).expect("root"); assert_eq!( Confined::open_new_file(confined.root.as_fd(), OsStr::new("occupied")) .expect_err("an occupied name is refused"), @@ -2723,7 +2984,7 @@ mod tests { use nix::fcntl::{FcntlArg, FdFlag, fcntl}; let dir = TempDir::new().expect("tempdir"); - let confined = Confined::open(dir.path()).expect("root"); + let confined = Confined::open(dir.path(), None).expect("root"); let fd = Confined::open_new_file(confined.root.as_fd(), OsStr::new("new")) .expect("a new file opens"); let descriptor_flags = FdFlag::from_bits_retain(fcntl(&fd, FcntlArg::F_GETFD).unwrap()); @@ -2732,4 +2993,311 @@ mod tests { file.write_all(b"contents").expect("the file is writable"); assert_eq!(std::fs::read(dir.path().join("new")).unwrap(), b"contents"); } + + // ── ownership under AGENTD_DEFAULT_USER ───────────────────────────────── + + /// The uid and gid of this test process, which it can always chown an entry to. + fn own_owner() -> Owner { + Owner { + uid: nix::unistd::getuid().as_raw(), + gid: nix::unistd::getgid().as_raw(), + } + } + + /// An owner this unprivileged test can't give an entry: root's. A chown to it is + /// `EPERM`, so an extraction that chowns an entry fails, and one that leaves every entry + /// alone succeeds. These tests run unprivileged, like the exec tests that name uid 65534. + const ROOT: Owner = Owner { uid: 0, gid: 0 }; + + fn is_eperm(result: &Result) -> bool { + matches!(result, Err(Refusal::Io(err)) if err.raw_os_error() == Some(Errno::EPERM as i32)) + } + + /// Extracts `archive` into an existing root holding nothing, with `owner`. + fn extract_owned(archive: Vec, owner: Owner) -> (TempDir, Result) { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().join("root"); + std::fs::create_dir(&root).expect("root"); + let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), Some(owner)); + (dir, result) + } + + /// Each kind of entry an archive creates is chowned: a file, a directory member, a + /// parent directory the archive only implies, and a symlink, each alone in an existing + /// root, and the directories on the way to a root that doesn't exist yet. With an owner + /// the process may give, the same archives land. + #[test] + fn an_upload_gives_each_entry_it_creates_the_owner() { + use std::os::unix::fs::MetadataExt; + let archives = [ + ("a file", Archive::new().file("f", b"x").bytes()), + ("a directory", Archive::new().dir("d", 0o755).bytes()), + ( + "an implied parent", + Archive::new().file("p/f", b"x").bytes(), + ), + ( + "a symlink", + Archive::new().link(EntryType::Symlink, "s", "f").bytes(), + ), + ]; + for (kind, archive) in archives { + let (_dir, result) = extract_owned(archive.clone(), ROOT); + assert!(is_eperm(&result), "{kind} wasn't chowned: {result:?}"); + let (dir, result) = extract_owned(archive, own_owner()); + assert!(result.is_ok(), "{kind}: {result:?}"); + for entry in std::fs::read_dir(dir.path().join("root")).expect("root") { + let meta = entry.expect("entry").metadata().expect("lstat"); + assert_eq!((meta.uid(), meta.gid()), (own_owner().uid, own_owner().gid)); + } + } + + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().join("a/b/root"); + let empty = Archive::new().bytes(); + let result = extract_into(&root, empty.as_slice(), OPEN, &roomy_guard(), Some(ROOT)); + assert!( + is_eperm(&result), + "the root's new parents weren't chowned: {result:?}" + ); + let result = extract_into( + &root, + empty.as_slice(), + OPEN, + &roomy_guard(), + Some(own_owner()), + ); + assert!(result.is_ok() && root.is_dir(), "{result:?}"); + let result = extract_into(&root, empty.as_slice(), OPEN, &roomy_guard(), Some(ROOT)); + assert!( + result.is_ok(), + "an existing root keeps its owner: {result:?}" + ); + } + + /// What was already there keeps its owner: an existing directory isn't chowned, a file + /// or symlink the archive replaces takes the owner of the entry it replaced, and a hard + /// link changes no inode's owner. Under an owner this process can't give, the archive + /// lands only if none of them is chowned to it. + #[test] + fn an_upload_leaves_what_already_existed_its_owner() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().join("root"); + std::fs::create_dir_all(root.join("d")).expect("dirs"); + std::fs::write(root.join("d/f"), b"old").expect("file"); + std::os::unix::fs::symlink("old-target", root.join("l")).expect("symlink"); + let archive = Archive::new() + .dir("d", 0o755) + .file("d/f", b"new") + .link(EntryType::Symlink, "l", "d/f") + .link(EntryType::Link, "h", "d/f") + .bytes(); + let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), Some(ROOT)); + assert_eq!(result.expect("nothing was chowned to root"), 4); + assert_eq!(std::fs::read(root.join("l")).expect("link"), b"new"); + assert_eq!(std::fs::read(root.join("h")).expect("hard link"), b"new"); + } + + /// A symlink the kernel won't create is refused naming the link, the member's last + /// component. This test runs unprivileged: root writes through a 0o555 directory. + #[test] + fn a_symlink_its_directory_refuses_is_refused_naming_the_link() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().join("root"); + std::fs::create_dir_all(root.join("ro")).expect("dirs"); + std::fs::set_permissions(root.join("ro"), std::fs::Permissions::from_mode(0o555)) + .expect("read-only"); + let archive = Archive::new().link(EntryType::Symlink, "ro/s", "x").bytes(); + let reason = refusal_reason(extract_into( + &root, + archive.as_slice(), + OPEN, + &roomy_guard(), + None, + )); + assert!( + reason.contains("cannot create under s: directory permissions"), + "{reason}" + ); + } + + /// A symlink is chowned through a descriptor of the link itself, and a name that holds + /// anything else by the time it's opened is refused, so a hard link swapped in for the + /// link can't take the chown. + #[test] + fn a_symlinks_chown_opens_the_link_itself() { + let dir = TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("plain"), b"x").expect("file"); + std::os::unix::fs::symlink("plain", dir.path().join("link")).expect("symlink"); + let confined = Confined::open(dir.path(), None).expect("root"); + let link = Confined::open_link(confined.root.as_fd(), OsStr::new("link")).expect("a link"); + let kind = SFlag::from_bits_truncate(fstat(&link).expect("fstat").st_mode) & SFlag::S_IFMT; + assert_eq!(kind, SFlag::S_IFLNK); + assert_eq!( + Confined::open_link(confined.root.as_fd(), OsStr::new("plain")) + .expect_err("a file isn't the link"), + Errno::EEXIST + ); + } + + /// A root reached through a symlink the caller named still opens: the components that + /// already exist are followed, as `create_dir_all` follows them, and only the ones the + /// walk creates are opened without following one. + #[test] + fn an_owned_root_follows_the_callers_symlinks() { + let dir = TempDir::new().expect("tempdir"); + std::fs::create_dir(dir.path().join("real")).expect("dir"); + std::os::unix::fs::symlink("real", dir.path().join("via")).expect("symlink"); + let root = dir.path().join("via/root"); + let archive = Archive::new().file("f", b"x").bytes(); + let result = extract_into( + &root, + archive.as_slice(), + OPEN, + &roomy_guard(), + Some(own_owner()), + ); + assert_eq!(result.expect("extracts"), 1); + assert!(dir.path().join("real/root/f").is_file()); + } + + /// A daemon whose image sets `AGENTD_DEFAULT_USER` to `default`, over a passwd that lists + /// root and `me`, this process's own uid and gid. + fn defaulted_state(dir: &TempDir, default: &str) -> AppState { + let passwd = dir.path().join("passwd"); + let me = own_owner(); + std::fs::write( + &passwd, + format!( + "root:x:0:0::/root:/bin/sh\nme:x:{}:{}::/home/me:/bin/sh\n", + me.uid, me.gid + ), + ) + .expect("passwd"); + AppState::with_probe( + Config { + passwd_path: passwd, + default_user: Some(default.into()), + ..Config::default() + }, + roomy_probe, + crate::identity::no_repair(), + ) + } + + fn put(route: &str, path: &Path, body: Vec) -> Request { + axum::http::Request::builder() + .method("PUT") + .uri(format!("/v1/fs/{route}?path={}", path.display())) + .body(Body::from(body)) + .expect("request") + } + + async fn error_body(response: Response) -> serde_json::Value { + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("body"); + serde_json::from_slice(&body).expect("a JSON error body") + } + + /// A default user with no passwd row, a name or digits, refuses a tar upload and a file + /// write as `400 unknown_user` naming the variable, as it refuses a start, and before + /// anything is written: not the root, not a parent, not the file. + #[tokio::test] + async fn an_upload_under_a_default_with_no_row_is_refused_before_anything_lands() { + for default in ["ghost", "4242"] { + let dir = TempDir::new().expect("tempdir"); + let state = defaulted_state(&dir, default); + let root = dir.path().join("tree/root"); + let archive = Archive::new().file("f", b"x").bytes(); + let response = write_tar(State(state.clone()), put("tar", &root, archive)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = error_body(response).await; + assert_eq!(body["error"], ERROR_UNKNOWN_USER, "{body}"); + let detail = body["detail"].as_str().expect("detail"); + assert!( + detail.contains(&format!("{default:?}")) && detail.contains("AGENTD_DEFAULT_USER"), + "{detail}" + ); + assert_eq!( + detail.contains("needs a passwd row"), + default == "4242", + "{detail}" + ); + + let file = dir.path().join("file/new.txt"); + let response = write_file(State(state), put("file", &file, b"x".to_vec())).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(error_body(response).await["error"], ERROR_UNKNOWN_USER); + assert!(!dir.path().join("tree").exists() && !dir.path().join("file").exists()); + } + } + + /// The handlers resolve the default to its row and hand that owner to the write: under + /// `root`, by name or as `0`, a new entry's chown is the `EPERM` this unprivileged test + /// expects, and under its own row everything lands. + #[tokio::test] + async fn the_routes_give_new_entries_the_default_users_row() { + for default in ["root", "0"] { + let dir = TempDir::new().expect("tempdir"); + let state = defaulted_state(&dir, default); + let archive = Archive::new().file("f", b"x").bytes(); + let response = write_tar( + State(state.clone()), + put("tar", &dir.path().join("t"), archive), + ) + .await; + assert_eq!( + response.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "{default}" + ); + let response = write_file( + State(state), + put("file", &dir.path().join("p/f"), b"x".to_vec()), + ) + .await; + assert_eq!( + response.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "{default}" + ); + } + + let dir = TempDir::new().expect("tempdir"); + let state = defaulted_state(&dir, "me"); + let archive = Archive::new().file("sub/f", b"tar").bytes(); + let root = dir.path().join("t"); + let response = write_tar(State(state.clone()), put("tar", &root, archive)).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + std::fs::read(root.join("sub/f")).expect("extracted"), + b"tar" + ); + let file = dir.path().join("p/q/f"); + let response = write_file(State(state), put("file", &file, b"file".to_vec())).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(std::fs::read(&file).expect("written"), b"file"); + } + + /// A file write chowns only what it creates. A new file it can't give the owner is + /// removed and answered 500; an existing file is truncated in place and keeps its owner. + #[tokio::test] + async fn a_file_write_gives_the_owner_only_a_file_it_creates() { + let dir = TempDir::new().expect("tempdir"); + let state = defaulted_state(&dir, "root"); + let file = dir.path().join("f"); + let response = write_file(State(state.clone()), put("file", &file, b"x".to_vec())).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + !file.exists(), + "a file that couldn't take its owner is removed" + ); + + std::fs::write(&file, b"old").expect("existing file"); + let response = write_file(State(state), put("file", &file, b"new".to_vec())).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(std::fs::read(&file).expect("written"), b"new"); + } } diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs index eccaf3c9..79f54117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs @@ -602,7 +602,12 @@ pub const FS_READ_FILE: &[Status] = &[ ]; pub const FS_WRITE_FILE: &[Status] = &[ - Status::new(StatusCode::NO_CONTENT, "", "written, with the mode applied"), + Status::new( + StatusCode::NO_CONTENT, + "", + "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or \ + parent directory the write creates belongs to the default user.", + ), Status::new( StatusCode::BAD_REQUEST, "", @@ -610,6 +615,7 @@ pub const FS_WRITE_FILE: &[Status] = &[ mode. Checked before a byte lands, so a rejected mode leaves nothing \ behind.", ), + UPLOAD_UNKNOWN_USER, UNAUTHORIZED, NOT_BOOTSTRAPPED, BODY_TOO_LARGE, @@ -617,7 +623,8 @@ pub const FS_WRITE_FILE: &[Status] = &[ Status::new( StatusCode::INTERNAL_SERVER_ERROR, "", - "the parent could not be created, or the write or chmod failed", + "the parent could not be created, the write or chmod failed, or a file the \ + write created could not be given the default user, in which case it is removed", ), ]; @@ -654,8 +661,24 @@ pub const FS_READ_TAR: &[Status] = &[ ), ]; +/// The refusal both upload routes share when the image's default user has no passwd row. +/// Unlike the routes' other refusals, its body is the JSON `ErrorBody`, since the slug is +/// the one a start answers. +const UPLOAD_UNKNOWN_USER: Status = Status::new( + StatusCode::BAD_REQUEST, + "unknown_user", + "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so \ + nothing the upload creates could belong to it. Checked before the body is read; \ + nothing was written, and detail names the value and the variable.", +); + pub const FS_WRITE_TAR: &[Status] = &[ - Status::new(StatusCode::NO_CONTENT, "", "extracted under ?path="), + Status::new( + StatusCode::NO_CONTENT, + "", + "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction \ + creates belongs to the default user.", + ), Status::new( StatusCode::BAD_REQUEST, "", @@ -664,6 +687,7 @@ pub const FS_WRITE_TAR: &[Status] = &[ path, an absolute or out-of-tree link target, or a device or fifo member. \ The refused member's name is in the body.", ), + UPLOAD_UNKNOWN_USER, UNAUTHORIZED, NOT_BOOTSTRAPPED, Status::new( diff --git a/crates/agentd/tests/bdd_exec_start.rs b/crates/agentd/tests/bdd_exec_start.rs index a235186b..be06096b 100644 --- a/crates/agentd/tests/bdd_exec_start.rs +++ b/crates/agentd/tests/bdd_exec_start.rs @@ -214,6 +214,13 @@ async fn default_user(world: &mut Daemon, name: String) { world.boot(Some(name)).await; } +/// [`default_user`] with the default spelled as this process's uid, which the background's +/// "tester" row carries. +#[given("the image sets AGENTD_DEFAULT_USER to this process's uid")] +async fn default_user_uid(world: &mut Daemon) { + world.boot(Some(own("-u"))).await; +} + #[given( expr = "the guest's passwd also lists {string} with this process's uid and gid and home {string}" )] diff --git a/crates/agentd/tests/features/exec_start.feature b/crates/agentd/tests/features/exec_start.feature index 47fa545e..c7127529 100644 --- a/crates/agentd/tests/features/exec_start.feature +++ b/crates/agentd/tests/features/exec_start.feature @@ -210,6 +210,16 @@ Feature: A start request names its user, group and shell, and may inherit the im And output line 2 is this process's gid And output line 3 is "/home/tester tester" + @AGENTD-22 @AGENTD-9 + Scenario: an all-digit default runs as the passwd row of that uid + Given the image sets AGENTD_DEFAULT_USER to this process's uid + And a start of "id -u; id -g; echo $HOME $USER" under shell "true" + When the daemon answers the start + Then the start was accepted and the child exited 0 + And output line 1 is this process's uid + And output line 2 is this process's gid + And output line 3 is "/home/tester tester" + @AGENTD-22 Scenario: the default is resolved at each start, so a user added after startup resolves Given the image sets AGENTD_DEFAULT_USER to "latecomer" @@ -242,3 +252,12 @@ Feature: A start request names its user, group and shell, and may inherit the im Then the answer is 400 with error "unknown_user" naming "AGENTD_DEFAULT_USER" And the answer is 400 with error "unknown_user" naming "nobody-here" And no child was spawned + + @AGENTD-24 + Scenario: an all-digit default with no passwd row refuses a start that names no user + Given the image sets AGENTD_DEFAULT_USER to "4242424" + And a start that would leave a marker file, under shell "true" + When the daemon answers the start + Then the answer is 400 with error "unknown_user" naming "AGENTD_DEFAULT_USER" + And the answer is 400 with error "unknown_user" naming "needs a passwd row" + And no child was spawned diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs index 333dfae5..f7ca2089 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs @@ -16,7 +16,8 @@ //! 2. **The daemon's execs start from an empty environment**, so the agent's subshells //! find no `PATH` and every command exits 127. The environment file sets it. //! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent -//! cannot read. One root `chown` follows the uploads. +//! cannot read, unless the image sets `AGENTD_DEFAULT_USER`. One root `chown` follows the +//! uploads either way. //! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses //! wire API, with an inference-profile model id and hosted web search disabled. Its //! config file names that host for the launch region, and its credential comes from @@ -491,8 +492,8 @@ pub fn provisioning_files( /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// -/// The daemon writes every upload as root, so the `chown` is not optional and it runs -/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +/// The daemon writes an upload as root unless the image sets `AGENTD_DEFAULT_USER`, so the +/// `chown` is not optional and it runs as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming /// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user /// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs index 11db62c7..f6c4505a 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,8 +367,8 @@ impl Session { .map(String::from) .chain(doomed) .collect(); - // As root, by uid: the daemon wrote the tree as root, and an image's default user - // would be refused deleting it. + // As root, by uid: a deleted path can sit in a directory root owns, `/workspace` + // itself among them, and an image's default user would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) .with_user(Some(crate::session::ROOT_UID.into())) diff --git a/crates/model/src/exec_start.rs b/crates/model/src/exec_start.rs index ff364f56..da392509 100644 --- a/crates/model/src/exec_start.rs +++ b/crates/model/src/exec_start.rs @@ -35,8 +35,9 @@ //! The guest's databases are reduced to the cases that behave differently: a user given as //! an integer with and without a passwd row, a name that resolves, a name that does not, and //! an all-digit string with no row (read as a uid, the way `docker exec -u 1000` reads it). -//! The image's default user is always a string, so it takes the three string cases, or is -//! unset; the daemon reads it once at startup, so the model fixes it at [`Action::Boot`]. +//! The image's default user is always a string, and resolves only to a passwd row: a name +//! with or without one, or digits with or without a row of that uid, or unset. The daemon +//! reads it once at startup, so the model fixes it at [`Action::Boot`]. //! //! # Every always-property has a sometimes-property beside it //! @@ -169,7 +170,8 @@ pub enum UserReq { DigitsNoRow, } -/// The image's `AGENTD_DEFAULT_USER`: unset, or a string, reduced like a string `user`. +/// The image's `AGENTD_DEFAULT_USER`: unset, or a string, reduced to the cases that behave +/// differently. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum DefaultUser { /// Not set: a request that names no user runs as the daemon's own user. @@ -178,27 +180,48 @@ pub enum DefaultUser { NameKnown, /// A name with no row, not all digits. NameUnknown, - /// All digits naming no row: a uid. + /// All digits, the uid of a passwd row. + DigitsWithRow, + /// All digits, the uid of no row. DigitsNoRow, } impl DefaultUser { /// Every default the model boots with. - pub const ALL: [DefaultUser; 4] = [ + pub const ALL: [DefaultUser; 5] = [ DefaultUser::Unset, DefaultUser::NameKnown, DefaultUser::NameUnknown, + DefaultUser::DigitsWithRow, DefaultUser::DigitsNoRow, ]; - /// The `user` a request would have to send to be resolved the same way, or `None` when - /// the default is unset. + /// Whether the specification resolves this default to a passwd row, by its name or by + /// the uid its digits spell. A set default without one is refused (AGENTD-24). + pub fn has_row(self) -> bool { + matches!(self, DefaultUser::NameKnown | DefaultUser::DigitsWithRow) + } + + /// The `user` a request would have to send to be resolved the same way under the + /// specification, or `None` when the default is unset. A default with a row is that + /// row's name, digits included, so it takes the row's primary gid (AGENTD-22); one with + /// none is a name nobody has (AGENTD-24). pub fn as_request(self) -> Option { match self { DefaultUser::Unset => None, - DefaultUser::NameKnown => Some(UserReq::NameKnown), - DefaultUser::NameUnknown => Some(UserReq::NameUnknown), + DefaultUser::NameKnown | DefaultUser::DigitsWithRow => Some(UserReq::NameKnown), + DefaultUser::NameUnknown | DefaultUser::DigitsNoRow => Some(UserReq::NameUnknown), + } + } + + /// The `user` the same string would be in a request: digits are a uid, with a row or + /// without, which keeps the daemon's group. [`Behavior::DefaultReadAsRequestUser`] + /// resolves the default this way. + fn as_request_string(self) -> Option { + match self { + DefaultUser::DigitsWithRow => Some(UserReq::IdWithRow), DefaultUser::DigitsNoRow => Some(UserReq::DigitsNoRow), + default => default.as_request(), } } } @@ -442,6 +465,8 @@ enum Defaulting { OverridesRequest, /// As specified, except a default that names nobody falls back to the daemon's user. UnknownRunsAsDaemon, + /// As a request's `user` string: digits are a bare uid that keeps the daemon's group. + AsRequestString, } /// Which layer order a daemon uses. @@ -465,7 +490,10 @@ fn plan( ) -> Result { // The user to resolve, and whose it is. The specification is the request's own, then the // default's, then the daemon's. - let default = world.default_user.as_request(); + let default = match defaulting { + Defaulting::AsRequestString => world.default_user.as_request_string(), + _ => world.default_user.as_request(), + }; let (account, user) = match (request.user, default, defaulting) { (_, Some(default), Defaulting::OverridesRequest) => (Account::Default, default), (UserReq::Absent, Some(_), Defaulting::Ignored) => (Account::Daemon, UserReq::Absent), @@ -562,6 +590,9 @@ pub enum Behavior { /// Runs a request that names no user as the daemon when the default names nobody: the /// silent fallback to root an image author would not see. UnknownDefaultRunsAsDaemon, + /// Resolves the default as the same string in a request's `user`: an all-digit default + /// runs as a bare uid in the daemon's group 0, with a row or without one. + DefaultReadAsRequestUser, } impl Behavior { @@ -609,6 +640,7 @@ impl Behavior { Behavior::DefaultIgnored => Defaulting::Ignored, Behavior::DefaultOverridesRequest => Defaulting::OverridesRequest, Behavior::UnknownDefaultRunsAsDaemon => Defaulting::UnknownRunsAsDaemon, + Behavior::DefaultReadAsRequestUser => Defaulting::AsRequestString, _ => Defaulting::Specified, }; plan( @@ -1116,25 +1148,21 @@ impl Model for ExecStart { ), // ── AGENTD-22 ───────────────────────────────────────────────────── // - // A default that names nobody never starts, which is AGENTD-24's to say. + // A default with no row never starts, which is AGENTD-24's to say. One with a row + // runs as that row however the image spelled it: its uid, its primary gid unless + // the request names a group, and its identity. Property::::always( "AGENTD-22 a request that names no user runs as the default user", |_, s| match started(s) { Some((r, c)) if r.user == UserReq::Absent => match s.default_user { DefaultUser::Unset => c.account == Account::Daemon && c.uid == Uid::Daemon, - DefaultUser::NameKnown => { + default if default.has_row() => { c.account == Account::Default && c.uid == Uid::Row && (r.group != GroupReq::Absent || c.gid == Gid::RowPrimary) && c.env.get(Key::User) == Some(Source::Passwd) } - DefaultUser::DigitsNoRow => { - c.account == Account::Default - && c.uid == Uid::Requested - && (r.group != GroupReq::Absent || c.gid == Gid::Daemon) - && c.env.get(Key::User).is_none() - } - DefaultUser::NameUnknown => true, + _ => true, }, _ => true, }, @@ -1151,7 +1179,7 @@ impl Model for ExecStart { "AGENTD-22 witness: a request that names no user runs as an all-digit default", |_, s| { started(s).is_some_and(|(r, _)| { - r.user == UserReq::Absent && s.default_user == DefaultUser::DigitsNoRow + r.user == UserReq::Absent && s.default_user == DefaultUser::DigitsWithRow }) }, ), @@ -1173,12 +1201,15 @@ impl Model for ExecStart { }, ), // ── AGENTD-24 ───────────────────────────────────────────────────── + // A default with no row, digits included: a request's same digits would run as a + // bare uid, and the default refuses instead. Property::::always( "AGENTD-24 a default that names nobody is refused unknown_user and spawns no child", |_, s| match (&s.request, s.answer) { (Some(r), Some(answer)) if r.user == UserReq::Absent - && s.default_user == DefaultUser::NameUnknown => + && s.default_user != DefaultUser::Unset + && !s.default_user.has_row() => { answer == Answer::Refused(Code::UnknownUser) && s.child.is_none() } @@ -1195,6 +1226,16 @@ impl Model for ExecStart { }) }, ), + Property::::sometimes( + "AGENTD-24 witness: an all-digit default with no row is refused", + |_, s| { + refused(s).is_some_and(|(r, code)| { + r.user == UserReq::Absent + && s.default_user == DefaultUser::DigitsNoRow + && code == Code::UnknownUser + }) + }, + ), // ── liveness ────────────────────────────────────────────────────── // // Sound because the model is acyclic: every action moves a state that no action @@ -1366,11 +1407,36 @@ mod tests { ) .into_actions(); assert!( - path.contains(&Action::Boot(DefaultUser::NameUnknown)), + path.contains(&Action::Boot(DefaultUser::NameUnknown)) + || path.contains(&Action::Boot(DefaultUser::DigitsNoRow)), "the fallback needs a default that names nobody, got {path:?}" ); } + /// **AGENTD-22, AGENTD-24.** A default read as a request's `user` string runs an + /// all-digit default as a bare uid: in the daemon's group 0 when the uid has a row, and + /// at all when it has none. + #[test] + fn a_default_read_as_a_request_string_keeps_the_daemons_group() { + let checker = checked(Behavior::DefaultReadAsRequestUser); + let path = checker + .assert_any_discovery("AGENTD-22 a request that names no user runs as the default user") + .into_actions(); + assert!( + path.contains(&Action::Boot(DefaultUser::DigitsWithRow)), + "the daemon's group needs an all-digit default with a row, got {path:?}" + ); + let path = checker + .assert_any_discovery( + "AGENTD-24 a default that names nobody is refused unknown_user and spawns no child", + ) + .into_actions(); + assert!( + path.contains(&Action::Boot(DefaultUser::DigitsNoRow)), + "the start needs an all-digit default with no row, got {path:?}" + ); + } + /// The table the daemon's tests mirror: the specification's answer for the cases the /// issues name. #[test] @@ -1474,6 +1540,42 @@ mod tests { Ok(Account::Requested) ); + // Digits the default spells are the row of that uid, primary gid and identity + // included, where a request's digits keep the daemon's group; with no row of that uid + // the default is refused, where a request's digits run as a bare uid. + let numbered = World { + default_user: DefaultUser::DigitsWithRow, + ..world + }; + let child = specified(&base, &numbered).expect("starts"); + assert_eq!( + (child.account, child.uid, child.gid, child.env), + (Account::Default, Uid::Row, Gid::RowPrimary, { + specified(&named, &world).expect("starts").env + }) + ); + let numbered_request = Request { + user: UserReq::IdWithRow, + ..base + }; + assert_eq!( + specified(&numbered_request, &world).map(|child| child.gid), + Ok(Gid::Daemon) + ); + let unnumbered = World { + default_user: DefaultUser::DigitsNoRow, + ..world + }; + assert_eq!(specified(&base, &unnumbered), Err(Code::UnknownUser)); + let digits_request = Request { + user: UserReq::DigitsNoRow, + ..base + }; + assert_eq!( + specified(&digits_request, &unnumbered).map(|child| child.uid), + Ok(Uid::Requested) + ); + // Flag set: image < passwd < launch < request. let inheriting = Request { inherit_image_env: true, diff --git a/crates/protocol/src/exec.rs b/crates/protocol/src/exec.rs index b5728183..748189c3 100644 --- a/crates/protocol/src/exec.rs +++ b/crates/protocol/src/exec.rs @@ -232,9 +232,10 @@ pub struct StartRequest { #[serde(default)] pub env: HashMap, /// The user to demote to: a numeric uid, or a name resolved against the guest's - /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as - /// that string here would be, or the daemon's own user when the image sets none. A name - /// the guest does not have answers 400 `unknown_user` before anything is spawned. + /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved to + /// the passwd row its name or uid matches, with that row's primary gid, or the daemon's + /// own user when the image sets none. A name the guest does not have, or a default with + /// no row, answers 400 `unknown_user` before anything is spawned. /// /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME` /// from the row, beneath the launch environment and `env`. A user given by name also diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 42454e1b..9ee3ad6f 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -212,7 +212,8 @@ semantics rather than `realpath`. Symlinks resolve relative to their own directory, while hard links resolve against the archive root. Member count and total size are capped. Modes are applied after content lands. A regular-file member replaces the old entry before creation, so other hard links keep their -bytes and modes. +bytes and modes. Extraction drops the archive's owners, as +[Upload ownership](#upload-ownership) describes. **A symlink an archive wrote cannot redirect a later member, and the kernel is what enforces that.** The `normpath` rule above judges a member at the depth its @@ -342,16 +343,20 @@ which is what protocol 1 always did. **An image can set the user a start without `user` runs as.** The image's `AGENTD_DEFAULT_USER` holds a user name or a string of digits. A start request -that omits `user` resolves that string exactly as it would resolve it in -`user`. A name gets its row's uid, primary gid, `HOME`, `USER` and `LOGNAME`, -and digits that match no name are a uid. The daemon resolves it at each -request, so a user an earlier exec created resolves on the next. A request's -own `user` always wins, `root` and `0` included, and `group` still overrides the -primary gid. If the guest's `/etc/passwd` doesn't hold the value, the daemon -answers each start that omits `user` with `400 unknown_user`, and the `detail` -mentions `AGENTD_DEFAULT_USER`. The daemon refuses an empty value the same way. It still +that omits `user` resolves that string to a row of the guest's `/etc/passwd`: +the row a name matches, or the row whose uid the digits spell. Either way the +child gets the row's uid, primary gid, `HOME`, `USER` and `LOGNAME`. That's +stricter than `user`, where digits keep the daemon's gid and don't need a row. The +daemon resolves it at each request, so a user an earlier exec created resolves +on the next. A request's own `user` always wins, `root` and `0` included, and +`group` still overrides the primary gid. If no row matches, the daemon answers +each start that omits `user` with `400 unknown_user`, and the `detail` mentions +`AGENTD_DEFAULT_USER`. For digits, the `detail` also says the image needs a +passwd row for that uid. The daemon refuses an empty value the same way. It still boots, and a request that sets `user` still runs. With the variable unset, a start that omits `user` runs as the daemon's own user, root, as it always has. +What an upload creates belongs to the same row, as +[Upload ownership](#upload-ownership) describes. Workload hook handlers ignore the variable, and the image snapshot drops it with every other `AGENTD_*` variable. `/v1/health` doesn't report it, because a new `Health` field would break the protocol crate's API. The daemon logs the value @@ -405,6 +410,25 @@ shape (AGENTD-7 through AGENTD-16 in `verify/spec/agentd.symspec.json`), includi no refused request ever has a child and that neither the token nor `AGENTD_*` configuration reaches one. +## Upload ownership + +**With `AGENTD_DEFAULT_USER` set, what an upload creates belongs to the image's +default user.** `PUT /v1/fs/tar` and `PUT /v1/fs/file` give each entry they +create the uid and primary gid of the row a start without `user` resolves to. +That covers files, directories, symlinks, and the parent directories the daemon +makes for them, so a nameless exec can write the tree an upload made for it. An +entry that was already there keeps its owner: an existing directory, the +extraction root among them, and a file `PUT /v1/fs/file` truncates. When an +archive member replaces an existing file or symlink, the new entry takes the old +one's owner, and a hard link doesn't change an owner. The daemon +changes each owner through a descriptor of the entry itself, opened without +following a symlink, never by path, so a name swapped mid-upload can't redirect +it. A failed change of owner answers 500. With the variable unset, every entry +an upload creates is root's, as it always was. If the default has no passwd +row, the daemon refuses the upload before writing anything, with the start's +`400 unknown_user`. That body is the JSON `{error, detail}` of the exec routes, +where the other fs refusals are plain text. + ## Line-ranged text reads `GET /v1/fs/file` takes optional `start_line` and `end_line`. The semantics are diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4aed49e8..5c80d3bd 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1401,7 +1401,7 @@ trace it. | A guest answers a verified tunnel's handshake with a key other than the pinned VM key | BIND-21 | `crates/microvms-core/tests/tunnel_end_to_end.rs::a_wrong_pin_fails_closed_with_a_diagnosis`, `crates/microvms-core/tests/tunnel_end_to_end.rs::a_reply_that_does_not_verify_against_the_pin_fails_the_tunnel` | guarded | | A guest or the path replays, reorders or forges tunnel frames after the handshake | AGENTD-21, BIND-24 | `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_replayed_frame_ends_the_tunnel_unrelayed`, `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_forged_frame_ends_the_tunnel_unrelayed`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_replayed_frame_fails_the_tunnel_unwritten`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_forged_frame_fails_the_tunnel_unwritten`, `crates/model/src/tunnel.rs::bind_24_the_model_finds_a_replay_when_frames_carry_no_position`, `crates/agentd/src/tunnel_fuzz.rs::frames_open_in_order_and_the_callers_end_only_after_every_chunk`, `crates/microvms-edges/src/session/tunnel_fuzz.rs::frames_open_in_order_and_the_daemons_end_only_after_every_chunk` | guarded | | A guest streams hostile server-sent events to the client | BIND-22 | `crates/microvms-app/src/session/sse.rs::an_unterminated_stream_is_refused_at_the_pending_ceiling`, `crates/microvms-app/src/session/sse.rs::an_unrecognized_or_unparseable_frame_is_dropped_rather_than_raised`, `crates/microvms-app/src/session/sse_fuzz.rs::hostile_stream_bytes_stay_bounded_and_every_event_round_trips` | guarded | -| A workload whose caller omits `user` runs as guest root | AGENTD-22, AGENTD-24 | `crates/agentd/src/exec.rs::a_start_that_names_no_user_runs_as_the_default_resolved_per_request`, `crates/agentd/src/exec_start.rs::a_default_that_names_nobody_is_refused_naming_the_variable`, `crates/model/src/exec_start.rs::an_unknown_default_falling_back_to_the_daemon_starts_as_root` | guarded when the image sets `AGENTD_DEFAULT_USER`. Without it such a workload runs as root, and with it an authorized caller can still ask for root | +| A workload whose caller omits `user` runs as guest root | AGENTD-22, AGENTD-24 | `crates/agentd/src/exec.rs::a_start_that_names_no_user_runs_as_the_default_resolved_per_request`, `crates/agentd/src/exec.rs::a_numeric_default_runs_as_its_row_and_one_with_no_row_is_refused`, `crates/agentd/src/exec_start.rs::a_default_that_names_nobody_is_refused_naming_the_variable`, `crates/model/src/exec_start.rs::an_unknown_default_falling_back_to_the_daemon_starts_as_root`, `crates/model/src/exec_start.rs::a_default_read_as_a_request_string_keeps_the_daemons_group` | guarded when the image sets `AGENTD_DEFAULT_USER`. Without it such a workload runs as root, and with it an authorized caller can still ask for root | | A replaced or tampered daemon release asset | BIND-18 | `crates/microvms-edges/src/provision/release.rs::another_signer_identity_is_refused`, `crates/microvms-edges/src/provision/release.rs::one_flipped_byte_in_the_asset_is_refused` | guarded | | A release directory (`$MICROVM_RELEASE_DIR`) holding an asset and a matching `SHA256SUMS` but no bundle, or another release's files under the requested tag | BIND-18 | `crates/microvms-edges/src/provision/release.rs::a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum`, `crates/microvms-edges/src/provision/release.rs::another_releases_files_under_the_requested_tag_are_refused` | guarded | | An on-path party ends a verified tunnel early, with a plaintext close frame or by dropping the connection | BIND-23, AGENTD-19, AGENTD-20 | `crates/microvms-edges/tests/tunnel_end_of_stream.rs::a_verified_tcp_consumer_observes_the_stream_verdict`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_close_frame_forged_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_connection_dropped_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_the_daemons_end_of_stream_withheld_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::agentd_20_the_clients_end_of_stream_withheld_resets_the_guest`, `crates/agentd/tests/tunnel_relay.rs::agentd_19_a_guest_eof_reaches_the_caller_as_the_end_of_stream` | guarded. A protocol 2 peer that omits the end-of-stream offer ends `ClosedUnproven`. Each side refuses a protocol 1 handshake | diff --git a/docs/TRUST.md b/docs/TRUST.md index 97701531..1a8ffc9f 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -20,11 +20,13 @@ resources. User demotion changes the workload UID within the same sandbox. The m and authentication tests do not prove isolation from guest root. A start request that omits `user` runs as root unless the image sets -`AGENTD_DEFAULT_USER`. With it set, that start runs as the image's user, and a -value the guest lacks refuses the start instead of running it as root. The -setting decides what a caller gets without asking. An authorized caller can -still execute as root by asking for `root` or `0`, and workload hook handlers -still run as root. +`AGENTD_DEFAULT_USER`. With it set, that start runs as the image's user, with +its passwd row's uid and primary gid, whether the image gives the user's name +or its uid. A value with no passwd row, digits included, refuses the start +instead of running it as root or in root's group. What an upload creates +belongs to that user too, so the workload can rewrite it. An authorized caller +can still execute as root by asking for `root` or `0`, and workload hook +handlers still run as root. ## Platform isolation guarantees @@ -125,7 +127,7 @@ and it renders the table in [Traceability](TRACEABILITY.md). A guard is written | A guest answers a verified tunnel's handshake with a key other than the pinned VM key | `BIND-21` | `crates/microvms-core/tests/tunnel_end_to_end.rs::a_wrong_pin_fails_closed_with_a_diagnosis`, `crates/microvms-core/tests/tunnel_end_to_end.rs::a_reply_that_does_not_verify_against_the_pin_fails_the_tunnel` | guarded | | A guest or the path replays, reorders or forges tunnel frames after the handshake | `AGENTD-21`, `BIND-24` | `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_replayed_frame_ends_the_tunnel_unrelayed`, `crates/agentd/tests/tunnel_relay.rs::agentd_21_a_forged_frame_ends_the_tunnel_unrelayed`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_replayed_frame_fails_the_tunnel_unwritten`, `crates/microvms-edges/tests/tunnel_end_of_stream.rs::bind_24_a_forged_frame_fails_the_tunnel_unwritten`, `crates/model/src/tunnel.rs::bind_24_the_model_finds_a_replay_when_frames_carry_no_position`, `crates/agentd/src/tunnel_fuzz.rs::frames_open_in_order_and_the_callers_end_only_after_every_chunk`, `crates/microvms-edges/src/session/tunnel_fuzz.rs::frames_open_in_order_and_the_daemons_end_only_after_every_chunk` | guarded | | A guest streams hostile server-sent events to the client | `BIND-22` | `crates/microvms-app/src/session/sse.rs::an_unterminated_stream_is_refused_at_the_pending_ceiling`, `crates/microvms-app/src/session/sse.rs::an_unrecognized_or_unparseable_frame_is_dropped_rather_than_raised`, `crates/microvms-app/src/session/sse_fuzz.rs::hostile_stream_bytes_stay_bounded_and_every_event_round_trips` | guarded | -| A workload whose caller omits `user` runs as guest root | `AGENTD-22`, `AGENTD-24` | `crates/agentd/src/exec.rs::a_start_that_names_no_user_runs_as_the_default_resolved_per_request`, `crates/agentd/src/exec_start.rs::a_default_that_names_nobody_is_refused_naming_the_variable`, `crates/model/src/exec_start.rs::an_unknown_default_falling_back_to_the_daemon_starts_as_root` | guarded when the image sets `AGENTD_DEFAULT_USER`. Without it such a workload runs as root, and with it an authorized caller can still ask for root | +| A workload whose caller omits `user` runs as guest root | `AGENTD-22`, `AGENTD-24` | `crates/agentd/src/exec.rs::a_start_that_names_no_user_runs_as_the_default_resolved_per_request`, `crates/agentd/src/exec.rs::a_numeric_default_runs_as_its_row_and_one_with_no_row_is_refused`, `crates/agentd/src/exec_start.rs::a_default_that_names_nobody_is_refused_naming_the_variable`, `crates/model/src/exec_start.rs::an_unknown_default_falling_back_to_the_daemon_starts_as_root`, `crates/model/src/exec_start.rs::a_default_read_as_a_request_string_keeps_the_daemons_group` | guarded when the image sets `AGENTD_DEFAULT_USER`. Without it such a workload runs as root, and with it an authorized caller can still ask for root | | A replaced or tampered daemon release asset | `BIND-18` | `crates/microvms-edges/src/provision/release.rs::another_signer_identity_is_refused`, `crates/microvms-edges/src/provision/release.rs::one_flipped_byte_in_the_asset_is_refused` | guarded | | A release directory (`$MICROVM_RELEASE_DIR`) holding an asset and a matching `SHA256SUMS` but no bundle, or another release's files under the requested tag | `BIND-18` | `crates/microvms-edges/src/provision/release.rs::a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum`, `crates/microvms-edges/src/provision/release.rs::another_releases_files_under_the_requested_tag_are_refused` | guarded | | An on-path party ends a verified tunnel early, with a plaintext close frame or by dropping the connection | `BIND-23`, `AGENTD-19`, `AGENTD-20` | `crates/microvms-edges/tests/tunnel_end_of_stream.rs::a_verified_tcp_consumer_observes_the_stream_verdict`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_close_frame_forged_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_a_connection_dropped_mid_stream_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::bind_23_the_daemons_end_of_stream_withheld_ends_truncated`, `crates/model-conformance/tests/tunnel_end_of_stream.rs::agentd_20_the_clients_end_of_stream_withheld_resets_the_guest`, `crates/agentd/tests/tunnel_relay.rs::agentd_19_a_guest_eof_reaches_the_caller_as_the_end_of_stream` | guarded. A protocol 2 peer that omits the end-of-stream offer ends `ClosedUnproven`. Each side refuses a protocol 1 handshake | diff --git a/docs/schema.json b/docs/schema.json index 4b430c83..c69accef 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -631,7 +631,7 @@ } ], "default": null, - "description": "The user to demote to: a numeric uid, or a name resolved against the guest's\n`/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as\nthat string here would be, or the daemon's own user when the image sets none. A name\nthe guest does not have answers 400 `unknown_user` before anything is spawned.\n\nA user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`\nfrom the row, beneath the launch environment and `env`. A user given by name also\ngets the row's primary gid when `group` is omitted; a numeric uid keeps the\ndaemon's group, as it always did." + "description": "The user to demote to: a numeric uid, or a name resolved against the guest's\n`/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved to\nthe passwd row its name or uid matches, with that row's primary gid, or the daemon's\nown user when the image sets none. A name the guest does not have, or a default with\nno row, answers 400 `unknown_user` before anything is spawned.\n\nA user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`\nfrom the row, beneath the launch environment and `env`. A user given by name also\ngets the row's primary gid when `group` is omitted; a numeric uid keeps the\ndaemon's group, as it always did." } }, "required": [ @@ -1392,13 +1392,18 @@ { "code": 204, "error": "", - "meaning": "written, with the mode applied" + "meaning": "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or parent directory the write creates belongs to the default user." }, { "code": 400, "error": "", "meaning": "the path query parameter is missing, or mode is not a valid octal file mode. Checked before a byte lands, so a rejected mode leaves nothing behind." }, + { + "code": 400, + "error": "unknown_user", + "meaning": "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so nothing the upload creates could belong to it. Checked before the body is read; nothing was written, and detail names the value and the variable." + }, { "code": 401, "error": "", @@ -1422,7 +1427,7 @@ { "code": 500, "error": "", - "meaning": "the parent could not be created, or the write or chmod failed" + "meaning": "the parent could not be created, the write or chmod failed, or a file the write created could not be given the default user, in which case it is removed" } ], "summary": "write one file. Deliberately not confined to a root: the same token authorizes exec, so a root prefix would add no security while breaking harnesses that write to home directories and /etc." @@ -1500,13 +1505,18 @@ { "code": 204, "error": "", - "meaning": "extracted under ?path=" + "meaning": "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction creates belongs to the default user." }, { "code": 400, "error": "", "meaning": "the path query parameter is missing or not absolute, the body was truncated, or a member violated the extraction contract — an escaping path, an absolute or out-of-tree link target, or a device or fifo member. The refused member's name is in the body." }, + { + "code": 400, + "error": "unknown_user", + "meaning": "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so nothing the upload creates could belong to it. Checked before the body is read; nothing was written, and detail names the value and the variable." + }, { "code": 401, "error": "", diff --git a/site/authored/learn/operations/write-a-guest-dockerfile.md b/site/authored/learn/operations/write-a-guest-dockerfile.md index 43c2f557..3426d065 100644 --- a/site/authored/learn/operations/write-a-guest-dockerfile.md +++ b/site/authored/learn/operations/write-a-guest-dockerfile.md @@ -77,7 +77,9 @@ To make that user the default for every `exec` and `run --exec` without `--user` ENV AGENTD_DEFAULT_USER=agent ``` -The daemon resolves the value at each exec, the way it resolves `--user agent`, so the row can come from an earlier exec. An exec with `--user` runs as that user instead, `--user root` included, and hook handlers still run as root. +The daemon resolves the value at each exec to its `/etc/passwd` row, so the row can come from an earlier exec. A name and its uid are the same: `AGENTD_DEFAULT_USER=1000` runs as `agent` with group 1000 and `HOME=/workspace`, and a uid with no row fails the exec rather than run it in root's group. An exec with `--user` runs as that user instead, `--user root` included, and hook handlers still run as root. + +The default user also owns what `microvm cp` and `run . --exec` upload. The files and directories an upload creates are that user's, so the default user can edit the synced tree. Anything already in the guest keeps its owner. That includes `/workspace` itself, which the image's `chown 1000:1000 /workspace` hands to the agent. The daemon refuses an upload as `unknown_user` when the default has no row. ## 5. No secret goes in the image diff --git a/site/authored/learn/tutorial/long-lived-vm.md b/site/authored/learn/tutorial/long-lived-vm.md index df2f8fa9..6b4adc63 100644 --- a/site/authored/learn/tutorial/long-lived-vm.md +++ b/site/authored/learn/tutorial/long-lived-vm.md @@ -59,7 +59,7 @@ microvm cp --tar ./project.tar vm:/workspace --name dev microvm cp --tar vm:/workspace ./workspace-backup.tar --name dev ``` -`vm:/path` names the VM side; anything else is a local path. `--tar` moves a whole directory tree as an uncompressed tar archive, and extraction in the guest is confined so a hostile archive cannot write outside its target. `--mode` sets the permissions of an uploaded file, octal as a string (`644`, `0755`). The daemon writes as root. Choose a mode that permits the intended reader; +`vm:/path` names the VM side; anything else is a local path. `--tar` moves a whole directory tree as an uncompressed tar archive, and extraction in the guest is confined so a hostile archive cannot write outside its target. `--mode` sets the permissions of an uploaded file, octal as a string (`644`, `0755`). The daemon writes as root, so what an upload creates is root's unless the image sets `AGENTD_DEFAULT_USER`, which then owns it. Choose a mode that permits the intended reader; change ownership when a demoted workload needs to write the file or read an owner-only file. diff --git a/verify/guards/faults/default-user.toml b/verify/guards/faults/default-user.toml index c8720f7b..8931dae4 100644 --- a/verify/guards/faults/default-user.toml +++ b/verify/guards/faults/default-user.toml @@ -61,7 +61,120 @@ guard = "exec_start::tests::a_default_that_names_nobody_is_refused_naming_the_va run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start::tests::a_default_that_names_nobody_is_refused_naming_the_variable"] expect = "test-failed" suite = "rust" -transform = { file = "crates/agentd/src/exec_start.rs", replace = " .map(Some)\n .map_err(|_| {\n", with = " .map(Some)\n .or(Ok::<_, Rejection>(None))\n .map_err(|_| {\n" } +transform = { file = "crates/agentd/src/exec_start.rs", replace = " Err(why) => Err(Rejection::new(\n ERROR_UNKNOWN_USER,\n format!(\"the request names no user, and {why}\"),\n )),\n", with = " Err(_) => Ok(None),\n" } + +# AGENTD-22: an all-digit default resolved as a request's digits are, which keeps the daemon's +# gid 0 for a uid with a row (the review finding on #600: `uid=1000(agent) gid=0(root)`). +[[fault]] +id = "default-user-numeric-keeps-daemon-gid" +guard = "exec_start::tests::a_request_that_names_no_user_resolves_the_default_as_its_user" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start::tests::a_request_that_names_no_user_resolves_the_default_as_its_user"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " row: Some(row),\n primary_gid: true,\n })),\n", with = " row: Some(row),\n primary_gid: row.name == name,\n })),\n" } + +[[fault]] +id = "default-user-numeric-keeps-daemon-gid-fuzz" +guard = "exec_start_fuzz::start_resolution" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start_fuzz::start_resolution"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " row: Some(row),\n primary_gid: true,\n })),\n", with = " row: Some(row),\n primary_gid: row.name == name,\n })),\n" } + +# AGENTD-24: an all-digit default with no row run as a bare uid, as a request's digits are. +[[family]] +guard = "exec_start::tests::a_default_that_names_nobody_is_refused_naming_the_variable" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec_start::tests::a_default_that_names_nobody_is_refused_naming_the_variable"] +expect = "test-failed" +suite = "rust" + +[[family.fault]] +id = "default-user-digits-without-row-run" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " (None, Some(name)) => match default_user_row(name, rows) {\n", with = " (None, Some(name)) if digits(name).is_some() => {\n resolve_user(&NameOrId::Name(name.to_string()), rows).map(Some)\n }\n (None, Some(name)) => match default_user_row(name, rows) {\n" } + +[[fault]] +id = "default-user-digits-without-row-run-handler" +guard = "exec::tests::a_numeric_default_runs_as_its_row_and_one_with_no_row_is_refused" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "exec::tests::a_numeric_default_runs_as_its_row_and_one_with_no_row_is_refused"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/exec_start.rs", replace = " (None, Some(name)) => match default_user_row(name, rows) {\n", with = " (None, Some(name)) if digits(name).is_some() => {\n resolve_user(&NameOrId::Name(name.to_string()), rows).map(Some)\n }\n (None, Some(name)) => match default_user_row(name, rows) {\n" } + +# Uploads under the default user (the second review finding on #600): each entry an upload +# creates belongs to the default user's row, what existed keeps its owner, and a default with +# no row refuses the upload. The fs tests run unprivileged, so a chown to root is the EPERM +# that shows a chown happened. +[[fault]] +id = "upload-owner-dropped-at-the-tar-route" +guard = "fs::tests::the_routes_give_new_entries_the_default_users_row" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::the_routes_give_new_entries_the_default_users_row"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/fs.rs", replace = " extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n))\n", with = " extract_into(&root, spool, caps, &guard, owner.and(None)).map(|n| (root, n))\n" } + +[[fault]] +id = "upload-under-a-default-with-no-row-proceeds" +guard = "fs::tests::an_upload_under_a_default_with_no_row_is_refused_before_anything_lands" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::an_upload_under_a_default_with_no_row_is_refused_before_anything_lands"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/fs.rs", replace = " Err(why) => {\n let detail = format!(", with = " Err(_) if name.is_empty() || !name.is_empty() => Ok(None),\n Err(why) => {\n let detail = format!(" } + +[[family]] +guard = "fs::tests::an_upload_gives_each_entry_it_creates_the_owner" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::an_upload_gives_each_entry_it_creates_the_owner"] +expect = "test-failed" +suite = "rust" + +[[family.fault]] +id = "upload-new-directories-keep-roots-owner" +transform = { file = "crates/agentd/src/fs.rs", replace = " if created {\n self.own(current.as_fd(), None)\n", with = " if created && self.owner.is_none() {\n self.own(current.as_fd(), None)\n" } + +[[family.fault]] +id = "upload-new-files-keep-roots-owner" +transform = { file = "crates/agentd/src/fs.rs", replace = " self.own(fd.as_fd(), previous)\n", with = " self.own(fd.as_fd(), previous.or(Some(Owner { uid: u32::MAX, gid: u32::MAX })))\n" } + +[[family.fault]] +id = "upload-new-symlinks-keep-roots-owner" +transform = { file = "crates/agentd/src/fs.rs", replace = " if self.owner.is_some() {\n let link = Self::open_link(", with = " if self.owner.is_none() {\n let link = Self::open_link(" } + +[[family.fault]] +id = "upload-new-root-parents-keep-roots-owner" +transform = { file = "crates/agentd/src/fs.rs", replace = " if created {\n owner.apply(current.as_fd())?;\n", with = " if created && owner.uid == u32::MAX {\n owner.apply(current.as_fd())?;\n" } + +[[fault]] +id = "upload-replaced-entry-takes-the-default" +guard = "fs::tests::an_upload_leaves_what_already_existed_its_owner" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::an_upload_leaves_what_already_existed_its_owner"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/fs.rs", replace = " Some(owner) => previous.unwrap_or(owner).apply(fd),\n", with = " Some(owner) => previous.and(Some(owner)).unwrap_or(owner).apply(fd),\n" } + +[[fault]] +id = "upload-symlink-chown-takes-whatever-the-name-holds" +guard = "fs::tests::a_symlinks_chown_opens_the_link_itself" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::a_symlinks_chown_opens_the_link_itself"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/fs.rs", replace = " if kind == SFlag::S_IFLNK {\n", with = " if kind == SFlag::S_IFLNK || kind != SFlag::S_IFLNK {\n" } + +[[fault]] +id = "file-write-gives-a-new-file-no-owner" +guard = "fs::tests::a_file_write_gives_the_owner_only_a_file_it_creates" +run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::a_file_write_gives_the_owner_only_a_file_it_creates"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/agentd/src/fs.rs", replace = " if let (true, Some(owner)) = (created, owner)\n", with = " if let (true, Some(owner)) = (created && owner.is_none(), owner)\n" } + +# The model's AGENTD-22 property: an all-digit default with a row resolved as a request's digits +# are, a bare uid in the daemon's group. +[[fault]] +id = "model-exec-start-numeric-default-as-request" +guard = "exec_start::tests::the_specified_daemon_satisfies_every_property" +run = ["cargo", "test", "-p", "agentd-model", "--lib", "--", "--exact", "exec_start::tests::the_specified_daemon_satisfies_every_property"] +expect = "test-failed" +suite = "rust" +transform = { file = "crates/model/src/exec_start.rs", replace = " DefaultUser::NameKnown | DefaultUser::DigitsWithRow => Some(UserReq::NameKnown),\n", with = " DefaultUser::NameKnown => Some(UserReq::NameKnown),\n DefaultUser::DigitsWithRow => Some(UserReq::IdWithRow),\n" } # An empty or non-UTF-8 value read as unset, which would run every workload as root. [[fault]] diff --git a/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch b/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch index 8de9de0f..5f294b9d 100644 --- a/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch +++ b/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch @@ -70,7 +70,7 @@ index 24deb93..9ea2777 100644 /// template actually produces. `bool::from_str` takes only `true`/`false`, so /// `AGENTD_REPAIR_IDENTITY=0` would otherwise be ignored with a warning — and a diff --git a/crates/agentd/src/exec.rs b/crates/agentd/src/exec.rs -index 9b9fc01..0f53011 100644 +index c6bd7f9..e9684f0 100644 --- a/crates/agentd/src/exec.rs +++ b/crates/agentd/src/exec.rs @@ -1202,13 +1202,10 @@ fn build_command( @@ -100,10 +100,10 @@ index 9b9fc01..0f53011 100644 }, )?; diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs -index 1dfcce0..2e5d092 100644 +index 453b4f5..4e0d7b3 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs -@@ -19,15 +19,6 @@ +@@ -19,18 +19,6 @@ //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). //! @@ -111,15 +111,18 @@ index 1dfcce0..2e5d092 100644 -//! -//! It runs as the image's default user when the image sets `AGENTD_DEFAULT_USER`, and as the -//! daemon's own user (root) when it doesn't (AGENTD-22). The default is resolved here, per --//! request, exactly as a request's `user` string would be, and a default that names nobody --//! is refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's +-//! request, to a passwd row: the row its name matches, or the row of the uid its digits +-//! spell. Either way the child takes the row's uid, primary gid and identity, so a numeric +-//! default doesn't keep the daemon's gid 0. A default with no row, digits included, is +-//! refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's -//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default --//! decides what a caller gets by not saying, not what a caller may ask for. +-//! decides what a caller gets by not saying, not what a caller may ask for. The same row +-//! owns what an upload creates (`fs.rs`). -//! //! # The environment is five layers, lowest first //! //! 1. The image environment, only when the request sets `inherit_image_env`: what the -@@ -61,8 +52,6 @@ use protocol::exec::{ +@@ -64,8 +52,6 @@ use protocol::exec::{ NameOrId, Shell, StartRequest, }; @@ -128,7 +131,7 @@ index 1dfcce0..2e5d092 100644 /// A child's environment, or one layer of it. pub type Env = HashMap; -@@ -170,8 +159,6 @@ pub struct Guest<'a> { +@@ -173,8 +159,6 @@ pub struct Guest<'a> { pub passwd: &'a str, /// The group file's text. Empty when the file is absent. pub group: &'a str, @@ -137,17 +140,76 @@ index 1dfcce0..2e5d092 100644 /// Whether a path is an executable regular file. The real check is /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. pub is_executable: &'a dyn Fn(&Path) -> bool, -@@ -259,35 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, +@@ -217,10 +201,8 @@ impl Plan { + struct User<'a> { + uid: u32, + row: Option<&'a PasswdRow>, +- /// Takes the row's primary gid when the request names no group: a name the request +- /// gave that matched a row, or the image's default user. A uid the request gave, as an +- /// integer or as digits, keeps the daemon's group (AGENTD-16). +- primary_gid: bool, ++ /// Given as a name that matched a row, as opposed to a number. ++ named: bool, + } + + /// All ASCII digits that fit a `u32`: a string read as an id when it names no row. +@@ -239,21 +221,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, + NameOrId::Id(uid) => Ok(User { + uid: *uid, + row: by_uid(*uid), +- primary_gid: false, ++ named: false, + }), + NameOrId::Name(name) => { + if let Some(row) = rows.iter().find(|row| row.name == *name) { + return Ok(User { + uid: row.uid, + row: Some(row), +- primary_gid: true, ++ named: true, + }); + } + match digits(name) { + Some(uid) => Ok(User { + uid, + row: by_uid(uid), +- primary_gid: false, ++ named: false, + }), + None => Err(Rejection::new( + ERROR_UNKNOWN_USER, +@@ -264,56 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, } } +-/// The image's default user as its passwd row (AGENTD-22): the row its name matches, or the +-/// row of the uid its digits spell. +-/// +-/// Stricter than a request's `user`, where digits with no row run as a bare uid. The default +-/// runs every start that names no user and owns every entry an upload creates, and a uid +-/// with no row has no primary gid to give either, so the child would keep the daemon's gid +-/// 0. A default with no row is refused instead (AGENTD-24). The error is the clause a +-/// refusal's detail ends with, naming the value and the variable that set it, because the +-/// caller sent no name to look for and the fix is in the image. +-pub fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { +- match resolve_user(&NameOrId::Name(name.to_string()), rows) { +- Ok(User { row: Some(row), .. }) => Ok(row), +- Ok(User { row: None, .. }) => Err(format!( +- "the default user {name:?} that {DEFAULT_USER_VAR} sets is a uid with no row in \ +- the guest's /etc/passwd (the image needs a passwd row for it)" +- )), +- Err(_) => Err(format!( +- "the default user {name:?} that {DEFAULT_USER_VAR} sets is not in the guest's \ +- /etc/passwd" +- )), +- } +-} +- -/// The user a request resolves to: its own `user` when it names one (AGENTD-23), else the --/// image's default resolved as that string would be (AGENTD-22), else none, which is the --/// daemon's own user. +-/// image's default user's row (AGENTD-22), else none, which is the daemon's own user. -/// --/// A default that names nobody is `unknown_user` like a request's unknown name (AGENTD-24), --/// with a detail that says the request named no user and which variable set the one that --/// failed, because the caller sent no name to look for and the fix is in the image. +-/// A default with no row is `unknown_user` like a request's unknown name (AGENTD-24), with +-/// a detail that says the request named no user and which variable set the one that failed. -fn resolve_request_user<'a>( - request: &StartRequest, - default_user: Option<&str>, @@ -155,17 +217,17 @@ index 1dfcce0..2e5d092 100644 -) -> Result>, Rejection> { - match (&request.user, default_user) { - (Some(spec), _) => resolve_user(spec, rows).map(Some), -- (None, Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows) -- .map(Some) -- .map_err(|_| { -- Rejection::new( -- ERROR_UNKNOWN_USER, -- format!( -- "the request names no user, and the default user {name:?} that \ -- {DEFAULT_USER_VAR} sets is not in the guest's /etc/passwd" -- ), -- ) -- }), +- (None, Some(name)) => match default_user_row(name, rows) { +- Ok(row) => Ok(Some(User { +- uid: row.uid, +- row: Some(row), +- primary_gid: true, +- })), +- Err(why) => Err(Rejection::new( +- ERROR_UNKNOWN_USER, +- format!("the request names no user, and {why}"), +- )), +- }, - (None, None) => Ok(None), - } -} @@ -173,7 +235,7 @@ index 1dfcce0..2e5d092 100644 /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { match spec { -@@ -363,12 +321,16 @@ pub fn resolve_shell( +@@ -389,12 +321,16 @@ pub fn resolve_shell( /// environment's `PATH`), so the first field that cannot be resolved names the error. The /// empty-argv check comes last, as it did in `build_command` before this module existed. pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { @@ -192,6 +254,560 @@ index 1dfcce0..2e5d092 100644 let groups = match &request.group { Some(NameOrId::Name(_)) => parse_group(guest.group), +@@ -402,12 +338,11 @@ pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result Some(resolve_group(spec, &groups)?), +- // A named user takes its row's primary group, the way `su` and `docker -u name` do, +- // and so does the default user, however the image spelled it (AGENTD-22). A uid the +- // request gives keeps the daemon's group, which is what protocol 1 did (AGENTD-16). ++ // A named user takes its row's primary group, the way `su` and `docker -u name` do. ++ // A numeric uid keeps the daemon's group, which is what protocol 1 did (AGENTD-16). + None => user + .as_ref() +- .filter(|user| user.primary_gid) ++ .filter(|user| user.named) + .and_then(|user| user.row) + .map(|row| row.gid), + }; +diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 6844bcb..f6221ee 100644 +--- a/crates/agentd/src/fs.rs ++++ b/crates/agentd/src/fs.rs +@@ -65,25 +65,7 @@ + //! resolution that would leave the root stops with `EXDEV`. Both become a 400 that + //! names the member. The daemon no longer has to reason about whether a redirect is + //! safe, because a redirected write cannot happen. +-//! +-//! # Who owns what an upload creates +-//! +-//! The daemon writes as root, so without `AGENTD_DEFAULT_USER` every entry an upload creates +-//! is root's, as it always was. With it set, each entry `PUT /v1/fs/tar` or +-//! `PUT /v1/fs/file` creates, parent directories included, is owned by the default user's +-//! uid and primary gid, resolved by the rule a start that names no user uses +-//! (`exec_start::default_user_row`), so the user a nameless exec runs as can write the tree +-//! an upload made for it. An entry that was already there keeps its owner: an existing +-//! directory, a file `PUT /v1/fs/file` truncates, and a name the archive replaces, whose new +-//! entry takes the old one's owner. A hard link is a name for an inode that already exists, +-//! so it changes no owner. A default with no passwd row refuses the upload with +-//! `400 unknown_user` before anything is written. +-//! +-//! Each chown goes through a descriptor of the entry itself, never a path: the open file, +-//! or a directory or symlink opened in its confined parent without following a symlink. A +-//! name swapped between the create and the chown can't move the chown onto another file. + +-use std::borrow::Cow; + use std::collections::HashMap; + use std::ffi::{OsStr, OsString}; + use std::io::{self, Read, Seek, SeekFrom, Write}; +@@ -93,22 +75,19 @@ use std::path::{Component, Path, PathBuf}; + + use nix::errno::Errno; + use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, OpenHow, ResolveFlag, openat2}; +-use nix::sys::stat::{FchmodatFlags, Mode, SFlag, fchmodat, fstat, fstatat, mkdirat}; +-use nix::unistd::{Gid, Uid, UnlinkatFlags, fchownat, linkat, symlinkat, unlinkat}; ++use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; ++use nix::unistd::{UnlinkatFlags, linkat, symlinkat, unlinkat}; + +-use axum::Json; + use axum::body::Body; + use axum::extract::{Request, State}; + use axum::http::{StatusCode, header}; + use axum::response::{IntoResponse, Response}; + use bytes::Bytes; + use futures_util::TryStreamExt; +-use protocol::exec::{ERROR_UNKNOWN_USER, ErrorBody}; + use tar::EntryType; + use tokio_util::io::{ReaderStream, StreamReader}; + + use crate::disk::{self, CopyError}; +-use crate::exec_start::{default_user_row, parse_passwd, read_database}; + use crate::state::AppState; + + /// The two typed shapes in this module, re-exported from their original paths. The +@@ -144,116 +123,6 @@ fn insufficient_storage(path: &Path, reading: disk::Reading) -> Response { + .into_response() + } + +-/// The owner of an entry an upload creates: the image's default user's uid and primary gid. +-/// `None` in place of one, throughout this module, leaves an entry the daemon's, which is +-/// root. +-#[derive(Clone, Copy, Debug, Eq, PartialEq)] +-struct Owner { +- uid: u32, +- gid: u32, +-} +- +-impl Owner { +- /// Gives the entry `fd` refers to this owner. `fd` may be `O_PATH`: with an empty path +- /// and `AT_EMPTY_PATH`, `fchownat` changes the descriptor's own inode, a symlink's +- /// included. +- fn apply(self, fd: BorrowedFd<'_>) -> Result<(), Errno> { +- fchownat( +- fd, +- "", +- Some(Uid::from_raw(self.uid)), +- Some(Gid::from_raw(self.gid)), +- AtFlags::AT_EMPTY_PATH, +- ) +- } +-} +- +-/// The owner of what this upload creates, or the detail of the `400 unknown_user` that +-/// refuses it. +-/// +-/// Resolved at each upload from the guest's `/etc/passwd`, the way a start resolves the +-/// default, so a user an earlier exec created owns the next upload. A default with no row +-/// is refused here, before the body is read, because extracting it as root would leave +-/// the default user a tree it can't write. The `Err` is the detail rather than a +-/// `Response` so the variant stays small (clippy's `result_large_err`); +-/// [`unknown_default_user`] builds the answer. +-fn upload_owner(state: &AppState) -> Result, String> { +- let config = state.config(); +- let Some(name) = config.default_user.as_deref() else { +- return Ok(None); +- }; +- let rows = parse_passwd(&read_database(&config.passwd_path)); +- match default_user_row(name, &rows) { +- Ok(row) => Ok(Some(Owner { +- uid: row.uid, +- gid: row.gid, +- })), +- Err(why) => { +- let detail = format!("an upload's new entries belong to the default user, and {why}"); +- Err(detail) +- } +- } +-} +- +-/// The `400 unknown_user` that refuses an upload under a default with no passwd row. The +-/// body is the exec routes' JSON `ErrorBody`, since the slug is theirs, where this module's +-/// other refusals are plain text. +-fn unknown_default_user(detail: String) -> Response { +- tracing::warn!(detail, "upload refused"); +- ( +- StatusCode::BAD_REQUEST, +- Json(ErrorBody { +- error: Cow::Borrowed(ERROR_UNKNOWN_USER), +- detail, +- }), +- ) +- .into_response() +-} +- +-/// Opens the directory `path`, creating each missing component as `create_dir_all` would, +-/// and gives each component it creates to `owner`. +-/// +-/// Walked a component at a time from `/`, or from the working directory for a relative +-/// path, each step relative to the descriptor of the last. A component that already exists +-/// is followed wherever it leads, symlinks included, as `create_dir_all` follows them, +-/// because the path is the caller's. A component this walk creates is opened with +-/// [`Confined::RESOLVE`] and chowned through that descriptor, so a symlink put in its +-/// place after the `mkdirat` stops the walk instead of taking the chown. +-fn open_dirs_owned(path: &Path, owner: Owner) -> io::Result { +- let open = |dir: BorrowedFd<'_>, name: &OsStr, resolve: ResolveFlag| { +- let how = OpenHow::new().flags(Confined::DIRECTORY).resolve(resolve); +- openat2(dir, name, how) +- }; +- let start = if path.is_absolute() { "/" } else { "." }; +- let mut current = open( +- AT_FDCWD, +- OsStr::new(start), +- ResolveFlag::RESOLVE_NO_MAGICLINKS, +- )?; +- for component in path.components() { +- let name = match component { +- Component::Normal(name) => name, +- Component::ParentDir => OsStr::new(".."), +- Component::RootDir | Component::CurDir | Component::Prefix(_) => continue, +- }; +- let created = match mkdirat(¤t, name, Mode::from_bits_truncate(0o777)) { +- Ok(()) => true, +- Err(Errno::EEXIST) => false, +- Err(errno) => return Err(errno.into()), +- }; +- let resolve = if created { +- Confined::RESOLVE +- } else { +- ResolveFlag::RESOLVE_NO_MAGICLINKS +- }; +- current = open(current.as_fd(), name, resolve)?; +- if created { +- owner.apply(current.as_fd())?; +- } +- } +- Ok(current) +-} +- + /// Why an archive or member was refused. + /// + /// The refused member's name travels with the refusal so the 400 can name it. A +@@ -427,9 +296,6 @@ fn resolve_member(root: &Path, path: &Path) -> Landing { + /// read from or written to even if the descriptor leaks somewhere it should not. + struct Confined { + root: OwnedFd, +- /// Who owns each entry the extraction creates (see the module comment). `None` leaves +- /// them the daemon's. +- owner: Option, + } + + impl Confined { +@@ -463,19 +329,7 @@ impl Confined { + .union(ResolveFlag::RESOLVE_NO_SYMLINKS) + .union(ResolveFlag::RESOLVE_NO_MAGICLINKS); + +- /// The open flags of every directory on a walk: a path for `*at` calls only, which +- /// fails unless the name is a directory. +- const DIRECTORY: OFlag = OFlag::O_CLOEXEC +- .union(OFlag::O_PATH) +- .union(OFlag::O_DIRECTORY); +- +- /// The open flags of a symlink opened as itself, for its chown ([`Confined::open_link`]). +- const LINK: OFlag = OFlag::O_CLOEXEC +- .union(OFlag::O_PATH) +- .union(OFlag::O_NOFOLLOW); +- +- /// Opens `root`, creating it if it is absent, and holds it. With an `owner`, each +- /// directory created on the way to `root` is that owner's ([`open_dirs_owned`]). ++ /// Opens `root`, creating it if it is absent, and holds it. + /// + /// The root itself is opened by absolute path with only + /// `RESOLVE_NO_MAGICLINKS`, not with [`Self::RESOLVE`]. `RESOLVE_BENEATH` +@@ -493,18 +347,13 @@ impl Confined { + /// and `File::create` this replaced: falling back would mean the confinement a + /// caller cannot see silently became the weaker one, and issue #15 is what the + /// weaker one allows. +- fn open(root: &Path, owner: Option) -> io::Result { +- let root = match owner { +- Some(owner) => open_dirs_owned(root, owner)?, +- None => { +- std::fs::create_dir_all(root)?; +- let how = OpenHow::new() +- .flags(Self::DIRECTORY) +- .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); +- openat2(AT_FDCWD, root, how).map_err(io::Error::from)? +- } +- }; +- Ok(Self { root, owner }) ++ fn open(root: &Path) -> io::Result { ++ std::fs::create_dir_all(root)?; ++ let how = OpenHow::new() ++ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) ++ .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); ++ let root = openat2(AT_FDCWD, root, how).map_err(io::Error::from)?; ++ Ok(Self { root }) + } + + /// Opens the single component `name` under `dir` as a directory. +@@ -514,7 +363,9 @@ impl Confined { + /// the error name which component failed, and a 400 that names the component + /// is the difference between an operator fixing their tree and re-reading it. + fn open_dir(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Result { +- let how = OpenHow::new().flags(Self::DIRECTORY).resolve(Self::RESOLVE); ++ let how = OpenHow::new() ++ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) ++ .resolve(Self::RESOLVE); + openat2(dir, name, how) + } + +@@ -524,8 +375,7 @@ impl Confined { + /// not an error: an archive may name the same directory twice, and a partial + /// extraction that is retried has to converge. The open that follows decides + /// whether the existing name is usable, and a symlink there fails with +- /// `ELOOP` rather than being traversed. A directory this walk creates is given the +- /// extraction's owner through that descriptor; one that existed keeps its own. ++ /// `ELOOP` rather than being traversed. + fn make_dirs(&self, parts: &[OsString]) -> Result { + let mut current = self.root.try_clone().map_err(|err| WalkError { + component: None, +@@ -536,33 +386,17 @@ impl Confined { + // replayed by `apply_deferred_modes` after every member has landed. + // Applying it here is the defect that pass exists to avoid: a 0o500 + // directory blocks the writes of its own children. +- let created = match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) +- { +- Ok(()) => true, +- Err(Errno::EEXIST) => false, ++ match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) { ++ Ok(()) | Err(Errno::EEXIST) => {} + Err(errno) => return Err(WalkError::at(parts, index, errno)), +- }; ++ } + current = self + .open_dir(current.as_fd(), part) + .map_err(|errno| WalkError::at(parts, index, errno))?; +- if created { +- self.own(current.as_fd(), None) +- .map_err(|errno| WalkError::at(parts, index, errno))?; +- } + } + Ok(current) + } + +- /// Gives an entry this extraction created its owner: `previous`, the owner of the entry +- /// it replaced, when there was one, and the extraction's otherwise. Nothing without an +- /// extraction owner, so an unset default leaves every entry the daemon's. +- fn own(&self, fd: BorrowedFd<'_>, previous: Option) -> Result<(), Errno> { +- match self.owner { +- Some(owner) => previous.unwrap_or(owner).apply(fd), +- None => Ok(()), +- } +- } +- + /// Splits `parts` into the parent directory descriptor and the final name. + /// + /// Every leaf creation needs exactly this: a `dirfd` the kernel has already +@@ -594,13 +428,9 @@ impl Confined { + fn create_file(&self, parts: &[OsString]) -> Result { + let (parent, name) = self.parent_of(parts)?; + let last = parts.len() - 1; +- let previous = self.replace(parent.as_fd(), &name); ++ self.replace(parent.as_fd(), &name); + let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + .map_err(|errno| WalkError::at(parts, last, errno))?; +- // Through the descriptor `O_EXCL` created, which is this file whatever the name +- // holds by now. +- self.own(fd.as_fd(), previous) +- .map_err(|errno| WalkError::at(parts, last, errno))?; + Ok(std::fs::File::from(fd)) + } + +@@ -628,34 +458,9 @@ impl Confined { + fn create_symlink(&self, parts: &[OsString], target: &Path) -> Result<(), WalkError> { + let (parent, name) = self.parent_of(parts)?; + let last = parts.len() - 1; +- let previous = self.replace(parent.as_fd(), &name); ++ self.replace(parent.as_fd(), &name); + symlinkat(target, parent.as_fd(), name.as_os_str()) +- .map_err(|errno| WalkError::at(parts, last, errno))?; +- if self.owner.is_some() { +- let link = Self::open_link(parent.as_fd(), &name) +- .map_err(|errno| WalkError::at(parts, last, errno))?; +- self.own(link.as_fd(), previous) +- .map_err(|errno| WalkError::at(parts, last, errno))?; +- } +- Ok(()) +- } +- +- /// Opens the symlink `name` under `dir` itself, `O_PATH` and without following it, for +- /// its chown. +- /// +- /// The name is opened rather than chowned in place, and the result has to be a +- /// symlink, because a process that can write `dir` can swap the link for a hard link +- /// to another file between the `symlinkat` and the chown, and a chown by name would then +- /// hand that file to the default user. `EEXIST` says the name no longer holds the link. +- fn open_link(dir: BorrowedFd<'_>, name: &OsStr) -> Result { +- let how = OpenHow::new().flags(Self::LINK).resolve(Self::RESOLVE); +- let link = openat2(dir, name, how)?; +- let kind = SFlag::from_bits_truncate(fstat(&link)?.st_mode) & SFlag::S_IFMT; +- if kind == SFlag::S_IFLNK { +- Ok(link) +- } else { +- Err(Errno::EEXIST) +- } ++ .map_err(|errno| WalkError::at(parts, last, errno)) + } + + /// Creates a hard link at `parts` pointing at `source`, an archive-root-relative +@@ -674,7 +479,6 @@ impl Confined { + ) -> Result<(), HardLinkError> { + let (parent, name) = self.parent_of(parts).map_err(HardLinkError::Dest)?; + let last = parts.len() - 1; +- // No chown: the link names an inode that already exists, whose owner it keeps. + self.replace(parent.as_fd(), &name); + + // An empty source is the archive naming the root as a hard-link target. +@@ -720,25 +524,14 @@ impl Confined { + Ok(current) + } + +- /// Unlinks `name` under `dir`, ignoring every failure, and returns the owner of what +- /// was there when the extraction gives its entries an owner, so the new entry can keep +- /// it. ++ /// Unlinks `name` under `dir`, ignoring every failure. + /// + /// A file or link member replaces the directory entry before creation, so + /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is + /// `ENOENT`, which means there was nothing to replace, and any other means the +- /// create below fails and reports the real reason. The owner is read without following +- /// a symlink, so a replaced link reports its own. +- fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Option { +- let previous = self +- .owner +- .and_then(|_| fstatat(dir, name, AtFlags::AT_SYMLINK_NOFOLLOW).ok()) +- .map(|stat| Owner { +- uid: stat.st_uid, +- gid: stat.st_gid, +- }); ++ /// create below fails and reports the real reason. ++ fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { + let _ = unlinkat(dir, name, UnlinkatFlags::NoRemoveDir); +- previous + } + + /// Applies `mode` to `parts`, without following a symlink at the last component. +@@ -853,11 +646,10 @@ pub fn fuzz_extract( + probe: disk::available_bytes, + reserve: 0, + }; +- extract_into(root, archive, caps, &guard, None).map_err(|refusal| format!("{refusal:?}")) ++ extract_into(root, archive, caps, &guard).map_err(|refusal| format!("{refusal:?}")) + } + +-/// Extracts `archive` into `root`, enforcing the data-filter contract, with each entry it +-/// creates given to `owner` when there is one (see the module comment). ++/// Extracts `archive` into `root`, enforcing the data-filter contract. + /// + /// Synchronous on purpose: `tar`'s reader is blocking, so callers hand this to + /// `spawn_blocking` rather than pretending it is async. +@@ -866,18 +658,17 @@ fn extract_into( + archive: impl Read, + caps: Caps, + guard: &disk::Guard, +- owner: Option, + ) -> Result { + // The kernel's half of the confinement, held for the whole extraction. Every + // member below is created through this rather than by absolute path, so a + // component that turns out to be a symlink stops the write instead of + // redirecting it. See the module comment. +- let confined = Confined::open(root, owner)?; ++ let confined = Confined::open(root)?; + + let mut ar = tar::Archive::new(archive); +- // The archive's ownership is dropped for the same reason CPython's `data` filter +- // drops it: its uid/gid are the packing host's, meaningless here, and honoring them +- // would need privilege we should not spend. Who owns an entry is `owner`'s to say. ++ // Ownership is dropped for the same reason CPython's `data` filter drops it: ++ // the archive's uid/gid are the packing host's, meaningless here, and honoring ++ // them would need privilege we should not spend. + ar.set_preserve_permissions(false); + ar.set_preserve_ownerships(false); + ar.set_unpack_xattrs(false); +@@ -1404,8 +1195,7 @@ fn sliced_stream( + }) + } + +-/// Writes one file. Not confined to a root; see the module comment, which also says who +-/// owns the file and the parents this creates. ++/// Writes one file. Not confined to a root; see the module comment. + pub async fn write_file(State(state): State, request: Request) -> Response { + let query = match fs_query(&request) { + Ok(query) => query, +@@ -1432,13 +1222,6 @@ pub async fn write_file(State(state): State, request: Request) -> Resp + }, + }; + +- // Like the mode, before anything lands: a default user with no passwd row refuses the +- // write rather than leaving it a root-owned file. +- let owner = match upload_owner(&state) { +- Ok(owner) => owner, +- Err(detail) => return unknown_default_user(detail), +- }; +- + // Checked before the parent directory is created and before the file is + // opened, so a refused write leaves nothing behind — not even an empty file + // where the caller's data was supposed to go. An empty file at the target path +@@ -1447,61 +1230,24 @@ pub async fn write_file(State(state): State, request: Request) -> Resp + return insufficient_storage(&path, reading); + } + +- if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { +- let created = match owner { +- None => tokio::fs::create_dir_all(parent).await, +- Some(owner) => { +- let parent = parent.to_path_buf(); +- tokio::task::spawn_blocking(move || open_dirs_owned(&parent, owner).map(drop)) +- .await +- .unwrap_or_else(|err| Err(io::Error::other(err))) +- } +- }; +- if let Err(err) = created { +- tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); +- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); +- } ++ if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) ++ && let Err(err) = tokio::fs::create_dir_all(parent).await ++ { ++ tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); ++ return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); + } + +- let options = |create_new: bool| { +- let mut options = tokio::fs::OpenOptions::new(); +- options.write(true).truncate(true); +- if create_new { +- options.create_new(true); +- } else { +- options.create(true); +- } +- if let Some(mode) = mode { +- // Set at open, so the file never exists with a wider mode than asked for +- // even briefly. A separate chmod after writing leaves that window open, +- // which matters when the file is a credential. +- options.mode(mode); +- } +- options +- }; +- // With an owner to give, `O_EXCL` first, so the daemon knows whether this write +- // created the file: one it only truncates keeps its owner. +- let opened = match owner { +- None => options(false).open(&path).await.map(|file| (file, false)), +- // The usual failure is that the file exists, which the plain open truncates; any +- // other, the plain open meets again and reports. +- Some(_) => match options(true).open(&path).await { +- Ok(file) => Ok((file, true)), +- Err(_) => options(false).open(&path).await.map(|file| (file, false)), +- }, +- }; +- let mut file = match opened { +- Ok((file, created)) => { +- if let (true, Some(owner)) = (created, owner) +- && let Err(errno) = owner.apply(file.as_fd()) +- { +- // The file is new and empty, and nobody was told it exists. +- let _ = tokio::fs::remove_file(&path).await; +- tracing::warn!(path = %path.display(), %errno, "cannot give the file its owner"); +- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot set owner").into_response(); +- } +- file +- } ++ let mut options = tokio::fs::OpenOptions::new(); ++ options.write(true).create(true).truncate(true); ++ if let Some(mode) = mode { ++ // Set at open, so the file never exists with a wider mode than asked for ++ // even briefly. A separate chmod after writing leaves that window open, ++ // which matters when the file is a credential. ++ options.mode(mode); ++ } ++ ++ let mut file = match options.open(&path).await { ++ Ok(file) => file, + Err(err) => { + tracing::warn!(path = %path.display(), %err, "cannot open file for write"); + return (StatusCode::INTERNAL_SERVER_ERROR, "cannot write file").into_response(); +@@ -1718,13 +1464,6 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo + .into_response(); + } + +- // Before the body is spooled, so a default user with no passwd row refuses the upload +- // without receiving it and before any member lands. +- let owner = match upload_owner(&state) { +- Ok(owner) => owner, +- Err(detail) => return unknown_default_user(detail), +- }; +- + let caps = Caps { + max_members: state.config().max_tar_members, + max_bytes: state.config().max_tar_bytes, +@@ -1755,7 +1494,7 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo + }; + + let extracted = tokio::task::spawn_blocking(move || { +- extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n)) ++ extract_into(&root, spool, caps, &guard).map(|n| (root, n)) + }) + .await; + diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs index 1549ac9..b30f398 100644 --- a/crates/agentd/src/hook_handlers.rs @@ -207,22 +823,104 @@ index 1549ac9..b30f398 100644 //! over loopback from inside the guest, so any process in the VM can make a handler //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many //! times that can happen. +diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs +index 79f5411..eccaf3c 100644 +--- a/crates/agentd/src/schema.rs ++++ b/crates/agentd/src/schema.rs +@@ -602,12 +602,7 @@ pub const FS_READ_FILE: &[Status] = &[ + ]; + + pub const FS_WRITE_FILE: &[Status] = &[ +- Status::new( +- StatusCode::NO_CONTENT, +- "", +- "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or \ +- parent directory the write creates belongs to the default user.", +- ), ++ Status::new(StatusCode::NO_CONTENT, "", "written, with the mode applied"), + Status::new( + StatusCode::BAD_REQUEST, + "", +@@ -615,7 +610,6 @@ pub const FS_WRITE_FILE: &[Status] = &[ + mode. Checked before a byte lands, so a rejected mode leaves nothing \ + behind.", + ), +- UPLOAD_UNKNOWN_USER, + UNAUTHORIZED, + NOT_BOOTSTRAPPED, + BODY_TOO_LARGE, +@@ -623,8 +617,7 @@ pub const FS_WRITE_FILE: &[Status] = &[ + Status::new( + StatusCode::INTERNAL_SERVER_ERROR, + "", +- "the parent could not be created, the write or chmod failed, or a file the \ +- write created could not be given the default user, in which case it is removed", ++ "the parent could not be created, or the write or chmod failed", + ), + ]; + +@@ -661,24 +654,8 @@ pub const FS_READ_TAR: &[Status] = &[ + ), + ]; + +-/// The refusal both upload routes share when the image's default user has no passwd row. +-/// Unlike the routes' other refusals, its body is the JSON `ErrorBody`, since the slug is +-/// the one a start answers. +-const UPLOAD_UNKNOWN_USER: Status = Status::new( +- StatusCode::BAD_REQUEST, +- "unknown_user", +- "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so \ +- nothing the upload creates could belong to it. Checked before the body is read; \ +- nothing was written, and detail names the value and the variable.", +-); +- + pub const FS_WRITE_TAR: &[Status] = &[ +- Status::new( +- StatusCode::NO_CONTENT, +- "", +- "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction \ +- creates belongs to the default user.", +- ), ++ Status::new(StatusCode::NO_CONTENT, "", "extracted under ?path="), + Status::new( + StatusCode::BAD_REQUEST, + "", +@@ -687,7 +664,6 @@ pub const FS_WRITE_TAR: &[Status] = &[ + path, an absolute or out-of-tree link target, or a device or fifo member. \ + The refused member's name is in the body.", + ), +- UPLOAD_UNKNOWN_USER, + UNAUTHORIZED, + NOT_BOOTSTRAPPED, + Status::new( diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs -index 333dfae..6ca3bcc 100644 +index f7ca208..6ca3bcc 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs -@@ -492,9 +492,7 @@ pub fn provisioning_files( +@@ -16,8 +16,7 @@ + //! 2. **The daemon's execs start from an empty environment**, so the agent's subshells + //! find no `PATH` and every command exits 127. The environment file sets it. + //! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent +-//! cannot read, unless the image sets `AGENTD_DEFAULT_USER`. One root `chown` follows the +-//! uploads either way. ++//! cannot read. One root `chown` follows the uploads. + //! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses + //! wire API, with an inference-profile model id and hosted web search disabled. Its + //! config file names that host for the launch region, and its credential comes from +@@ -492,10 +491,8 @@ pub fn provisioning_files( + /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// - /// The daemon writes every upload as root, so the `chown` is not optional and it runs --/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +-/// The daemon writes an upload as root unless the image sets `AGENTD_DEFAULT_USER`, so the +-/// `chown` is not optional and it runs as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming -/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user -/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call ++/// The daemon writes every upload as root, so the `chown` is not optional and it runs +/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is /// refreshed on a long-lived VM (AGENT-8). pub async fn install_access( -@@ -512,7 +510,6 @@ pub async fn install_access( +@@ -513,7 +510,6 @@ pub async fn install_access( vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], ) .with_shell(true) @@ -249,15 +947,15 @@ index e83657c..16a4ab8 100644 /// /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs -index 11db62c..f3d4bd5 100644 +index f6c4505..f3d4bd5 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,11 +367,8 @@ impl Session { .map(String::from) .chain(doomed) .collect(); -- // As root, by uid: the daemon wrote the tree as root, and an image's default user -- // would be refused deleting it. +- // As root, by uid: a deleted path can sit in a directory root owns, `/workspace` +- // itself among them, and an image's default user would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) - .with_user(Some(crate::session::ROOT_UID.into())) @@ -312,22 +1010,23 @@ index bf71c6a..ef6a544 100644 .with_group(spec.group) // No `with_timeout_sec`: the client-side deadline is the caller's `--timeout`, applied diff --git a/crates/protocol/src/exec.rs b/crates/protocol/src/exec.rs -index b572818..bba0754 100644 +index 748189c..bba0754 100644 --- a/crates/protocol/src/exec.rs +++ b/crates/protocol/src/exec.rs -@@ -232,9 +232,8 @@ pub struct StartRequest { +@@ -232,10 +232,8 @@ pub struct StartRequest { #[serde(default)] pub env: HashMap, /// The user to demote to: a numeric uid, or a name resolved against the guest's -- /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as -- /// that string here would be, or the daemon's own user when the image sets none. A name -- /// the guest does not have answers 400 `unknown_user` before anything is spawned. +- /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved to +- /// the passwd row its name or uid matches, with that row's primary gid, or the daemon's +- /// own user when the image sets none. A name the guest does not have, or a default with +- /// no row, answers 400 `unknown_user` before anything is spawned. + /// `/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the + /// guest does not have answers 400 `unknown_user` before anything is spawned. /// /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME` /// from the row, beneath the launch environment and `env`. A user given by name also -@@ -551,8 +550,7 @@ impl StartRequest { +@@ -552,8 +550,7 @@ impl StartRequest { self } diff --git a/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch b/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch +++ b/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch b/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch +++ b/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch b/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch +++ b/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch b/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch +++ b/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch b/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch index 8de9de0f..5f294b9d 100644 --- a/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch +++ b/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch @@ -70,7 +70,7 @@ index 24deb93..9ea2777 100644 /// template actually produces. `bool::from_str` takes only `true`/`false`, so /// `AGENTD_REPAIR_IDENTITY=0` would otherwise be ignored with a warning — and a diff --git a/crates/agentd/src/exec.rs b/crates/agentd/src/exec.rs -index 9b9fc01..0f53011 100644 +index c6bd7f9..e9684f0 100644 --- a/crates/agentd/src/exec.rs +++ b/crates/agentd/src/exec.rs @@ -1202,13 +1202,10 @@ fn build_command( @@ -100,10 +100,10 @@ index 9b9fc01..0f53011 100644 }, )?; diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs -index 1dfcce0..2e5d092 100644 +index 453b4f5..4e0d7b3 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs -@@ -19,15 +19,6 @@ +@@ -19,18 +19,6 @@ //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). //! @@ -111,15 +111,18 @@ index 1dfcce0..2e5d092 100644 -//! -//! It runs as the image's default user when the image sets `AGENTD_DEFAULT_USER`, and as the -//! daemon's own user (root) when it doesn't (AGENTD-22). The default is resolved here, per --//! request, exactly as a request's `user` string would be, and a default that names nobody --//! is refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's +-//! request, to a passwd row: the row its name matches, or the row of the uid its digits +-//! spell. Either way the child takes the row's uid, primary gid and identity, so a numeric +-//! default doesn't keep the daemon's gid 0. A default with no row, digits included, is +-//! refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's -//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default --//! decides what a caller gets by not saying, not what a caller may ask for. +-//! decides what a caller gets by not saying, not what a caller may ask for. The same row +-//! owns what an upload creates (`fs.rs`). -//! //! # The environment is five layers, lowest first //! //! 1. The image environment, only when the request sets `inherit_image_env`: what the -@@ -61,8 +52,6 @@ use protocol::exec::{ +@@ -64,8 +52,6 @@ use protocol::exec::{ NameOrId, Shell, StartRequest, }; @@ -128,7 +131,7 @@ index 1dfcce0..2e5d092 100644 /// A child's environment, or one layer of it. pub type Env = HashMap; -@@ -170,8 +159,6 @@ pub struct Guest<'a> { +@@ -173,8 +159,6 @@ pub struct Guest<'a> { pub passwd: &'a str, /// The group file's text. Empty when the file is absent. pub group: &'a str, @@ -137,17 +140,76 @@ index 1dfcce0..2e5d092 100644 /// Whether a path is an executable regular file. The real check is /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. pub is_executable: &'a dyn Fn(&Path) -> bool, -@@ -259,35 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, +@@ -217,10 +201,8 @@ impl Plan { + struct User<'a> { + uid: u32, + row: Option<&'a PasswdRow>, +- /// Takes the row's primary gid when the request names no group: a name the request +- /// gave that matched a row, or the image's default user. A uid the request gave, as an +- /// integer or as digits, keeps the daemon's group (AGENTD-16). +- primary_gid: bool, ++ /// Given as a name that matched a row, as opposed to a number. ++ named: bool, + } + + /// All ASCII digits that fit a `u32`: a string read as an id when it names no row. +@@ -239,21 +221,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, + NameOrId::Id(uid) => Ok(User { + uid: *uid, + row: by_uid(*uid), +- primary_gid: false, ++ named: false, + }), + NameOrId::Name(name) => { + if let Some(row) = rows.iter().find(|row| row.name == *name) { + return Ok(User { + uid: row.uid, + row: Some(row), +- primary_gid: true, ++ named: true, + }); + } + match digits(name) { + Some(uid) => Ok(User { + uid, + row: by_uid(uid), +- primary_gid: false, ++ named: false, + }), + None => Err(Rejection::new( + ERROR_UNKNOWN_USER, +@@ -264,56 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, } } +-/// The image's default user as its passwd row (AGENTD-22): the row its name matches, or the +-/// row of the uid its digits spell. +-/// +-/// Stricter than a request's `user`, where digits with no row run as a bare uid. The default +-/// runs every start that names no user and owns every entry an upload creates, and a uid +-/// with no row has no primary gid to give either, so the child would keep the daemon's gid +-/// 0. A default with no row is refused instead (AGENTD-24). The error is the clause a +-/// refusal's detail ends with, naming the value and the variable that set it, because the +-/// caller sent no name to look for and the fix is in the image. +-pub fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { +- match resolve_user(&NameOrId::Name(name.to_string()), rows) { +- Ok(User { row: Some(row), .. }) => Ok(row), +- Ok(User { row: None, .. }) => Err(format!( +- "the default user {name:?} that {DEFAULT_USER_VAR} sets is a uid with no row in \ +- the guest's /etc/passwd (the image needs a passwd row for it)" +- )), +- Err(_) => Err(format!( +- "the default user {name:?} that {DEFAULT_USER_VAR} sets is not in the guest's \ +- /etc/passwd" +- )), +- } +-} +- -/// The user a request resolves to: its own `user` when it names one (AGENTD-23), else the --/// image's default resolved as that string would be (AGENTD-22), else none, which is the --/// daemon's own user. +-/// image's default user's row (AGENTD-22), else none, which is the daemon's own user. -/// --/// A default that names nobody is `unknown_user` like a request's unknown name (AGENTD-24), --/// with a detail that says the request named no user and which variable set the one that --/// failed, because the caller sent no name to look for and the fix is in the image. +-/// A default with no row is `unknown_user` like a request's unknown name (AGENTD-24), with +-/// a detail that says the request named no user and which variable set the one that failed. -fn resolve_request_user<'a>( - request: &StartRequest, - default_user: Option<&str>, @@ -155,17 +217,17 @@ index 1dfcce0..2e5d092 100644 -) -> Result>, Rejection> { - match (&request.user, default_user) { - (Some(spec), _) => resolve_user(spec, rows).map(Some), -- (None, Some(name)) => resolve_user(&NameOrId::Name(name.to_string()), rows) -- .map(Some) -- .map_err(|_| { -- Rejection::new( -- ERROR_UNKNOWN_USER, -- format!( -- "the request names no user, and the default user {name:?} that \ -- {DEFAULT_USER_VAR} sets is not in the guest's /etc/passwd" -- ), -- ) -- }), +- (None, Some(name)) => match default_user_row(name, rows) { +- Ok(row) => Ok(Some(User { +- uid: row.uid, +- row: Some(row), +- primary_gid: true, +- })), +- Err(why) => Err(Rejection::new( +- ERROR_UNKNOWN_USER, +- format!("the request names no user, and {why}"), +- )), +- }, - (None, None) => Ok(None), - } -} @@ -173,7 +235,7 @@ index 1dfcce0..2e5d092 100644 /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { match spec { -@@ -363,12 +321,16 @@ pub fn resolve_shell( +@@ -389,12 +321,16 @@ pub fn resolve_shell( /// environment's `PATH`), so the first field that cannot be resolved names the error. The /// empty-argv check comes last, as it did in `build_command` before this module existed. pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { @@ -192,6 +254,560 @@ index 1dfcce0..2e5d092 100644 let groups = match &request.group { Some(NameOrId::Name(_)) => parse_group(guest.group), +@@ -402,12 +338,11 @@ pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result Some(resolve_group(spec, &groups)?), +- // A named user takes its row's primary group, the way `su` and `docker -u name` do, +- // and so does the default user, however the image spelled it (AGENTD-22). A uid the +- // request gives keeps the daemon's group, which is what protocol 1 did (AGENTD-16). ++ // A named user takes its row's primary group, the way `su` and `docker -u name` do. ++ // A numeric uid keeps the daemon's group, which is what protocol 1 did (AGENTD-16). + None => user + .as_ref() +- .filter(|user| user.primary_gid) ++ .filter(|user| user.named) + .and_then(|user| user.row) + .map(|row| row.gid), + }; +diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 6844bcb..f6221ee 100644 +--- a/crates/agentd/src/fs.rs ++++ b/crates/agentd/src/fs.rs +@@ -65,25 +65,7 @@ + //! resolution that would leave the root stops with `EXDEV`. Both become a 400 that + //! names the member. The daemon no longer has to reason about whether a redirect is + //! safe, because a redirected write cannot happen. +-//! +-//! # Who owns what an upload creates +-//! +-//! The daemon writes as root, so without `AGENTD_DEFAULT_USER` every entry an upload creates +-//! is root's, as it always was. With it set, each entry `PUT /v1/fs/tar` or +-//! `PUT /v1/fs/file` creates, parent directories included, is owned by the default user's +-//! uid and primary gid, resolved by the rule a start that names no user uses +-//! (`exec_start::default_user_row`), so the user a nameless exec runs as can write the tree +-//! an upload made for it. An entry that was already there keeps its owner: an existing +-//! directory, a file `PUT /v1/fs/file` truncates, and a name the archive replaces, whose new +-//! entry takes the old one's owner. A hard link is a name for an inode that already exists, +-//! so it changes no owner. A default with no passwd row refuses the upload with +-//! `400 unknown_user` before anything is written. +-//! +-//! Each chown goes through a descriptor of the entry itself, never a path: the open file, +-//! or a directory or symlink opened in its confined parent without following a symlink. A +-//! name swapped between the create and the chown can't move the chown onto another file. + +-use std::borrow::Cow; + use std::collections::HashMap; + use std::ffi::{OsStr, OsString}; + use std::io::{self, Read, Seek, SeekFrom, Write}; +@@ -93,22 +75,19 @@ use std::path::{Component, Path, PathBuf}; + + use nix::errno::Errno; + use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, OpenHow, ResolveFlag, openat2}; +-use nix::sys::stat::{FchmodatFlags, Mode, SFlag, fchmodat, fstat, fstatat, mkdirat}; +-use nix::unistd::{Gid, Uid, UnlinkatFlags, fchownat, linkat, symlinkat, unlinkat}; ++use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; ++use nix::unistd::{UnlinkatFlags, linkat, symlinkat, unlinkat}; + +-use axum::Json; + use axum::body::Body; + use axum::extract::{Request, State}; + use axum::http::{StatusCode, header}; + use axum::response::{IntoResponse, Response}; + use bytes::Bytes; + use futures_util::TryStreamExt; +-use protocol::exec::{ERROR_UNKNOWN_USER, ErrorBody}; + use tar::EntryType; + use tokio_util::io::{ReaderStream, StreamReader}; + + use crate::disk::{self, CopyError}; +-use crate::exec_start::{default_user_row, parse_passwd, read_database}; + use crate::state::AppState; + + /// The two typed shapes in this module, re-exported from their original paths. The +@@ -144,116 +123,6 @@ fn insufficient_storage(path: &Path, reading: disk::Reading) -> Response { + .into_response() + } + +-/// The owner of an entry an upload creates: the image's default user's uid and primary gid. +-/// `None` in place of one, throughout this module, leaves an entry the daemon's, which is +-/// root. +-#[derive(Clone, Copy, Debug, Eq, PartialEq)] +-struct Owner { +- uid: u32, +- gid: u32, +-} +- +-impl Owner { +- /// Gives the entry `fd` refers to this owner. `fd` may be `O_PATH`: with an empty path +- /// and `AT_EMPTY_PATH`, `fchownat` changes the descriptor's own inode, a symlink's +- /// included. +- fn apply(self, fd: BorrowedFd<'_>) -> Result<(), Errno> { +- fchownat( +- fd, +- "", +- Some(Uid::from_raw(self.uid)), +- Some(Gid::from_raw(self.gid)), +- AtFlags::AT_EMPTY_PATH, +- ) +- } +-} +- +-/// The owner of what this upload creates, or the detail of the `400 unknown_user` that +-/// refuses it. +-/// +-/// Resolved at each upload from the guest's `/etc/passwd`, the way a start resolves the +-/// default, so a user an earlier exec created owns the next upload. A default with no row +-/// is refused here, before the body is read, because extracting it as root would leave +-/// the default user a tree it can't write. The `Err` is the detail rather than a +-/// `Response` so the variant stays small (clippy's `result_large_err`); +-/// [`unknown_default_user`] builds the answer. +-fn upload_owner(state: &AppState) -> Result, String> { +- let config = state.config(); +- let Some(name) = config.default_user.as_deref() else { +- return Ok(None); +- }; +- let rows = parse_passwd(&read_database(&config.passwd_path)); +- match default_user_row(name, &rows) { +- Ok(row) => Ok(Some(Owner { +- uid: row.uid, +- gid: row.gid, +- })), +- Err(why) => { +- let detail = format!("an upload's new entries belong to the default user, and {why}"); +- Err(detail) +- } +- } +-} +- +-/// The `400 unknown_user` that refuses an upload under a default with no passwd row. The +-/// body is the exec routes' JSON `ErrorBody`, since the slug is theirs, where this module's +-/// other refusals are plain text. +-fn unknown_default_user(detail: String) -> Response { +- tracing::warn!(detail, "upload refused"); +- ( +- StatusCode::BAD_REQUEST, +- Json(ErrorBody { +- error: Cow::Borrowed(ERROR_UNKNOWN_USER), +- detail, +- }), +- ) +- .into_response() +-} +- +-/// Opens the directory `path`, creating each missing component as `create_dir_all` would, +-/// and gives each component it creates to `owner`. +-/// +-/// Walked a component at a time from `/`, or from the working directory for a relative +-/// path, each step relative to the descriptor of the last. A component that already exists +-/// is followed wherever it leads, symlinks included, as `create_dir_all` follows them, +-/// because the path is the caller's. A component this walk creates is opened with +-/// [`Confined::RESOLVE`] and chowned through that descriptor, so a symlink put in its +-/// place after the `mkdirat` stops the walk instead of taking the chown. +-fn open_dirs_owned(path: &Path, owner: Owner) -> io::Result { +- let open = |dir: BorrowedFd<'_>, name: &OsStr, resolve: ResolveFlag| { +- let how = OpenHow::new().flags(Confined::DIRECTORY).resolve(resolve); +- openat2(dir, name, how) +- }; +- let start = if path.is_absolute() { "/" } else { "." }; +- let mut current = open( +- AT_FDCWD, +- OsStr::new(start), +- ResolveFlag::RESOLVE_NO_MAGICLINKS, +- )?; +- for component in path.components() { +- let name = match component { +- Component::Normal(name) => name, +- Component::ParentDir => OsStr::new(".."), +- Component::RootDir | Component::CurDir | Component::Prefix(_) => continue, +- }; +- let created = match mkdirat(¤t, name, Mode::from_bits_truncate(0o777)) { +- Ok(()) => true, +- Err(Errno::EEXIST) => false, +- Err(errno) => return Err(errno.into()), +- }; +- let resolve = if created { +- Confined::RESOLVE +- } else { +- ResolveFlag::RESOLVE_NO_MAGICLINKS +- }; +- current = open(current.as_fd(), name, resolve)?; +- if created { +- owner.apply(current.as_fd())?; +- } +- } +- Ok(current) +-} +- + /// Why an archive or member was refused. + /// + /// The refused member's name travels with the refusal so the 400 can name it. A +@@ -427,9 +296,6 @@ fn resolve_member(root: &Path, path: &Path) -> Landing { + /// read from or written to even if the descriptor leaks somewhere it should not. + struct Confined { + root: OwnedFd, +- /// Who owns each entry the extraction creates (see the module comment). `None` leaves +- /// them the daemon's. +- owner: Option, + } + + impl Confined { +@@ -463,19 +329,7 @@ impl Confined { + .union(ResolveFlag::RESOLVE_NO_SYMLINKS) + .union(ResolveFlag::RESOLVE_NO_MAGICLINKS); + +- /// The open flags of every directory on a walk: a path for `*at` calls only, which +- /// fails unless the name is a directory. +- const DIRECTORY: OFlag = OFlag::O_CLOEXEC +- .union(OFlag::O_PATH) +- .union(OFlag::O_DIRECTORY); +- +- /// The open flags of a symlink opened as itself, for its chown ([`Confined::open_link`]). +- const LINK: OFlag = OFlag::O_CLOEXEC +- .union(OFlag::O_PATH) +- .union(OFlag::O_NOFOLLOW); +- +- /// Opens `root`, creating it if it is absent, and holds it. With an `owner`, each +- /// directory created on the way to `root` is that owner's ([`open_dirs_owned`]). ++ /// Opens `root`, creating it if it is absent, and holds it. + /// + /// The root itself is opened by absolute path with only + /// `RESOLVE_NO_MAGICLINKS`, not with [`Self::RESOLVE`]. `RESOLVE_BENEATH` +@@ -493,18 +347,13 @@ impl Confined { + /// and `File::create` this replaced: falling back would mean the confinement a + /// caller cannot see silently became the weaker one, and issue #15 is what the + /// weaker one allows. +- fn open(root: &Path, owner: Option) -> io::Result { +- let root = match owner { +- Some(owner) => open_dirs_owned(root, owner)?, +- None => { +- std::fs::create_dir_all(root)?; +- let how = OpenHow::new() +- .flags(Self::DIRECTORY) +- .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); +- openat2(AT_FDCWD, root, how).map_err(io::Error::from)? +- } +- }; +- Ok(Self { root, owner }) ++ fn open(root: &Path) -> io::Result { ++ std::fs::create_dir_all(root)?; ++ let how = OpenHow::new() ++ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) ++ .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); ++ let root = openat2(AT_FDCWD, root, how).map_err(io::Error::from)?; ++ Ok(Self { root }) + } + + /// Opens the single component `name` under `dir` as a directory. +@@ -514,7 +363,9 @@ impl Confined { + /// the error name which component failed, and a 400 that names the component + /// is the difference between an operator fixing their tree and re-reading it. + fn open_dir(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Result { +- let how = OpenHow::new().flags(Self::DIRECTORY).resolve(Self::RESOLVE); ++ let how = OpenHow::new() ++ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) ++ .resolve(Self::RESOLVE); + openat2(dir, name, how) + } + +@@ -524,8 +375,7 @@ impl Confined { + /// not an error: an archive may name the same directory twice, and a partial + /// extraction that is retried has to converge. The open that follows decides + /// whether the existing name is usable, and a symlink there fails with +- /// `ELOOP` rather than being traversed. A directory this walk creates is given the +- /// extraction's owner through that descriptor; one that existed keeps its own. ++ /// `ELOOP` rather than being traversed. + fn make_dirs(&self, parts: &[OsString]) -> Result { + let mut current = self.root.try_clone().map_err(|err| WalkError { + component: None, +@@ -536,33 +386,17 @@ impl Confined { + // replayed by `apply_deferred_modes` after every member has landed. + // Applying it here is the defect that pass exists to avoid: a 0o500 + // directory blocks the writes of its own children. +- let created = match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) +- { +- Ok(()) => true, +- Err(Errno::EEXIST) => false, ++ match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) { ++ Ok(()) | Err(Errno::EEXIST) => {} + Err(errno) => return Err(WalkError::at(parts, index, errno)), +- }; ++ } + current = self + .open_dir(current.as_fd(), part) + .map_err(|errno| WalkError::at(parts, index, errno))?; +- if created { +- self.own(current.as_fd(), None) +- .map_err(|errno| WalkError::at(parts, index, errno))?; +- } + } + Ok(current) + } + +- /// Gives an entry this extraction created its owner: `previous`, the owner of the entry +- /// it replaced, when there was one, and the extraction's otherwise. Nothing without an +- /// extraction owner, so an unset default leaves every entry the daemon's. +- fn own(&self, fd: BorrowedFd<'_>, previous: Option) -> Result<(), Errno> { +- match self.owner { +- Some(owner) => previous.unwrap_or(owner).apply(fd), +- None => Ok(()), +- } +- } +- + /// Splits `parts` into the parent directory descriptor and the final name. + /// + /// Every leaf creation needs exactly this: a `dirfd` the kernel has already +@@ -594,13 +428,9 @@ impl Confined { + fn create_file(&self, parts: &[OsString]) -> Result { + let (parent, name) = self.parent_of(parts)?; + let last = parts.len() - 1; +- let previous = self.replace(parent.as_fd(), &name); ++ self.replace(parent.as_fd(), &name); + let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + .map_err(|errno| WalkError::at(parts, last, errno))?; +- // Through the descriptor `O_EXCL` created, which is this file whatever the name +- // holds by now. +- self.own(fd.as_fd(), previous) +- .map_err(|errno| WalkError::at(parts, last, errno))?; + Ok(std::fs::File::from(fd)) + } + +@@ -628,34 +458,9 @@ impl Confined { + fn create_symlink(&self, parts: &[OsString], target: &Path) -> Result<(), WalkError> { + let (parent, name) = self.parent_of(parts)?; + let last = parts.len() - 1; +- let previous = self.replace(parent.as_fd(), &name); ++ self.replace(parent.as_fd(), &name); + symlinkat(target, parent.as_fd(), name.as_os_str()) +- .map_err(|errno| WalkError::at(parts, last, errno))?; +- if self.owner.is_some() { +- let link = Self::open_link(parent.as_fd(), &name) +- .map_err(|errno| WalkError::at(parts, last, errno))?; +- self.own(link.as_fd(), previous) +- .map_err(|errno| WalkError::at(parts, last, errno))?; +- } +- Ok(()) +- } +- +- /// Opens the symlink `name` under `dir` itself, `O_PATH` and without following it, for +- /// its chown. +- /// +- /// The name is opened rather than chowned in place, and the result has to be a +- /// symlink, because a process that can write `dir` can swap the link for a hard link +- /// to another file between the `symlinkat` and the chown, and a chown by name would then +- /// hand that file to the default user. `EEXIST` says the name no longer holds the link. +- fn open_link(dir: BorrowedFd<'_>, name: &OsStr) -> Result { +- let how = OpenHow::new().flags(Self::LINK).resolve(Self::RESOLVE); +- let link = openat2(dir, name, how)?; +- let kind = SFlag::from_bits_truncate(fstat(&link)?.st_mode) & SFlag::S_IFMT; +- if kind == SFlag::S_IFLNK { +- Ok(link) +- } else { +- Err(Errno::EEXIST) +- } ++ .map_err(|errno| WalkError::at(parts, last, errno)) + } + + /// Creates a hard link at `parts` pointing at `source`, an archive-root-relative +@@ -674,7 +479,6 @@ impl Confined { + ) -> Result<(), HardLinkError> { + let (parent, name) = self.parent_of(parts).map_err(HardLinkError::Dest)?; + let last = parts.len() - 1; +- // No chown: the link names an inode that already exists, whose owner it keeps. + self.replace(parent.as_fd(), &name); + + // An empty source is the archive naming the root as a hard-link target. +@@ -720,25 +524,14 @@ impl Confined { + Ok(current) + } + +- /// Unlinks `name` under `dir`, ignoring every failure, and returns the owner of what +- /// was there when the extraction gives its entries an owner, so the new entry can keep +- /// it. ++ /// Unlinks `name` under `dir`, ignoring every failure. + /// + /// A file or link member replaces the directory entry before creation, so + /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is + /// `ENOENT`, which means there was nothing to replace, and any other means the +- /// create below fails and reports the real reason. The owner is read without following +- /// a symlink, so a replaced link reports its own. +- fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Option { +- let previous = self +- .owner +- .and_then(|_| fstatat(dir, name, AtFlags::AT_SYMLINK_NOFOLLOW).ok()) +- .map(|stat| Owner { +- uid: stat.st_uid, +- gid: stat.st_gid, +- }); ++ /// create below fails and reports the real reason. ++ fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { + let _ = unlinkat(dir, name, UnlinkatFlags::NoRemoveDir); +- previous + } + + /// Applies `mode` to `parts`, without following a symlink at the last component. +@@ -853,11 +646,10 @@ pub fn fuzz_extract( + probe: disk::available_bytes, + reserve: 0, + }; +- extract_into(root, archive, caps, &guard, None).map_err(|refusal| format!("{refusal:?}")) ++ extract_into(root, archive, caps, &guard).map_err(|refusal| format!("{refusal:?}")) + } + +-/// Extracts `archive` into `root`, enforcing the data-filter contract, with each entry it +-/// creates given to `owner` when there is one (see the module comment). ++/// Extracts `archive` into `root`, enforcing the data-filter contract. + /// + /// Synchronous on purpose: `tar`'s reader is blocking, so callers hand this to + /// `spawn_blocking` rather than pretending it is async. +@@ -866,18 +658,17 @@ fn extract_into( + archive: impl Read, + caps: Caps, + guard: &disk::Guard, +- owner: Option, + ) -> Result { + // The kernel's half of the confinement, held for the whole extraction. Every + // member below is created through this rather than by absolute path, so a + // component that turns out to be a symlink stops the write instead of + // redirecting it. See the module comment. +- let confined = Confined::open(root, owner)?; ++ let confined = Confined::open(root)?; + + let mut ar = tar::Archive::new(archive); +- // The archive's ownership is dropped for the same reason CPython's `data` filter +- // drops it: its uid/gid are the packing host's, meaningless here, and honoring them +- // would need privilege we should not spend. Who owns an entry is `owner`'s to say. ++ // Ownership is dropped for the same reason CPython's `data` filter drops it: ++ // the archive's uid/gid are the packing host's, meaningless here, and honoring ++ // them would need privilege we should not spend. + ar.set_preserve_permissions(false); + ar.set_preserve_ownerships(false); + ar.set_unpack_xattrs(false); +@@ -1404,8 +1195,7 @@ fn sliced_stream( + }) + } + +-/// Writes one file. Not confined to a root; see the module comment, which also says who +-/// owns the file and the parents this creates. ++/// Writes one file. Not confined to a root; see the module comment. + pub async fn write_file(State(state): State, request: Request) -> Response { + let query = match fs_query(&request) { + Ok(query) => query, +@@ -1432,13 +1222,6 @@ pub async fn write_file(State(state): State, request: Request) -> Resp + }, + }; + +- // Like the mode, before anything lands: a default user with no passwd row refuses the +- // write rather than leaving it a root-owned file. +- let owner = match upload_owner(&state) { +- Ok(owner) => owner, +- Err(detail) => return unknown_default_user(detail), +- }; +- + // Checked before the parent directory is created and before the file is + // opened, so a refused write leaves nothing behind — not even an empty file + // where the caller's data was supposed to go. An empty file at the target path +@@ -1447,61 +1230,24 @@ pub async fn write_file(State(state): State, request: Request) -> Resp + return insufficient_storage(&path, reading); + } + +- if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { +- let created = match owner { +- None => tokio::fs::create_dir_all(parent).await, +- Some(owner) => { +- let parent = parent.to_path_buf(); +- tokio::task::spawn_blocking(move || open_dirs_owned(&parent, owner).map(drop)) +- .await +- .unwrap_or_else(|err| Err(io::Error::other(err))) +- } +- }; +- if let Err(err) = created { +- tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); +- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); +- } ++ if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) ++ && let Err(err) = tokio::fs::create_dir_all(parent).await ++ { ++ tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); ++ return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); + } + +- let options = |create_new: bool| { +- let mut options = tokio::fs::OpenOptions::new(); +- options.write(true).truncate(true); +- if create_new { +- options.create_new(true); +- } else { +- options.create(true); +- } +- if let Some(mode) = mode { +- // Set at open, so the file never exists with a wider mode than asked for +- // even briefly. A separate chmod after writing leaves that window open, +- // which matters when the file is a credential. +- options.mode(mode); +- } +- options +- }; +- // With an owner to give, `O_EXCL` first, so the daemon knows whether this write +- // created the file: one it only truncates keeps its owner. +- let opened = match owner { +- None => options(false).open(&path).await.map(|file| (file, false)), +- // The usual failure is that the file exists, which the plain open truncates; any +- // other, the plain open meets again and reports. +- Some(_) => match options(true).open(&path).await { +- Ok(file) => Ok((file, true)), +- Err(_) => options(false).open(&path).await.map(|file| (file, false)), +- }, +- }; +- let mut file = match opened { +- Ok((file, created)) => { +- if let (true, Some(owner)) = (created, owner) +- && let Err(errno) = owner.apply(file.as_fd()) +- { +- // The file is new and empty, and nobody was told it exists. +- let _ = tokio::fs::remove_file(&path).await; +- tracing::warn!(path = %path.display(), %errno, "cannot give the file its owner"); +- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot set owner").into_response(); +- } +- file +- } ++ let mut options = tokio::fs::OpenOptions::new(); ++ options.write(true).create(true).truncate(true); ++ if let Some(mode) = mode { ++ // Set at open, so the file never exists with a wider mode than asked for ++ // even briefly. A separate chmod after writing leaves that window open, ++ // which matters when the file is a credential. ++ options.mode(mode); ++ } ++ ++ let mut file = match options.open(&path).await { ++ Ok(file) => file, + Err(err) => { + tracing::warn!(path = %path.display(), %err, "cannot open file for write"); + return (StatusCode::INTERNAL_SERVER_ERROR, "cannot write file").into_response(); +@@ -1718,13 +1464,6 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo + .into_response(); + } + +- // Before the body is spooled, so a default user with no passwd row refuses the upload +- // without receiving it and before any member lands. +- let owner = match upload_owner(&state) { +- Ok(owner) => owner, +- Err(detail) => return unknown_default_user(detail), +- }; +- + let caps = Caps { + max_members: state.config().max_tar_members, + max_bytes: state.config().max_tar_bytes, +@@ -1755,7 +1494,7 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo + }; + + let extracted = tokio::task::spawn_blocking(move || { +- extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n)) ++ extract_into(&root, spool, caps, &guard).map(|n| (root, n)) + }) + .await; + diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs index 1549ac9..b30f398 100644 --- a/crates/agentd/src/hook_handlers.rs @@ -207,22 +823,104 @@ index 1549ac9..b30f398 100644 //! over loopback from inside the guest, so any process in the VM can make a handler //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many //! times that can happen. +diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs +index 79f5411..eccaf3c 100644 +--- a/crates/agentd/src/schema.rs ++++ b/crates/agentd/src/schema.rs +@@ -602,12 +602,7 @@ pub const FS_READ_FILE: &[Status] = &[ + ]; + + pub const FS_WRITE_FILE: &[Status] = &[ +- Status::new( +- StatusCode::NO_CONTENT, +- "", +- "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or \ +- parent directory the write creates belongs to the default user.", +- ), ++ Status::new(StatusCode::NO_CONTENT, "", "written, with the mode applied"), + Status::new( + StatusCode::BAD_REQUEST, + "", +@@ -615,7 +610,6 @@ pub const FS_WRITE_FILE: &[Status] = &[ + mode. Checked before a byte lands, so a rejected mode leaves nothing \ + behind.", + ), +- UPLOAD_UNKNOWN_USER, + UNAUTHORIZED, + NOT_BOOTSTRAPPED, + BODY_TOO_LARGE, +@@ -623,8 +617,7 @@ pub const FS_WRITE_FILE: &[Status] = &[ + Status::new( + StatusCode::INTERNAL_SERVER_ERROR, + "", +- "the parent could not be created, the write or chmod failed, or a file the \ +- write created could not be given the default user, in which case it is removed", ++ "the parent could not be created, or the write or chmod failed", + ), + ]; + +@@ -661,24 +654,8 @@ pub const FS_READ_TAR: &[Status] = &[ + ), + ]; + +-/// The refusal both upload routes share when the image's default user has no passwd row. +-/// Unlike the routes' other refusals, its body is the JSON `ErrorBody`, since the slug is +-/// the one a start answers. +-const UPLOAD_UNKNOWN_USER: Status = Status::new( +- StatusCode::BAD_REQUEST, +- "unknown_user", +- "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so \ +- nothing the upload creates could belong to it. Checked before the body is read; \ +- nothing was written, and detail names the value and the variable.", +-); +- + pub const FS_WRITE_TAR: &[Status] = &[ +- Status::new( +- StatusCode::NO_CONTENT, +- "", +- "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction \ +- creates belongs to the default user.", +- ), ++ Status::new(StatusCode::NO_CONTENT, "", "extracted under ?path="), + Status::new( + StatusCode::BAD_REQUEST, + "", +@@ -687,7 +664,6 @@ pub const FS_WRITE_TAR: &[Status] = &[ + path, an absolute or out-of-tree link target, or a device or fifo member. \ + The refused member's name is in the body.", + ), +- UPLOAD_UNKNOWN_USER, + UNAUTHORIZED, + NOT_BOOTSTRAPPED, + Status::new( diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs -index 333dfae..6ca3bcc 100644 +index f7ca208..6ca3bcc 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs -@@ -492,9 +492,7 @@ pub fn provisioning_files( +@@ -16,8 +16,7 @@ + //! 2. **The daemon's execs start from an empty environment**, so the agent's subshells + //! find no `PATH` and every command exits 127. The environment file sets it. + //! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent +-//! cannot read, unless the image sets `AGENTD_DEFAULT_USER`. One root `chown` follows the +-//! uploads either way. ++//! cannot read. One root `chown` follows the uploads. + //! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses + //! wire API, with an inference-profile model id and hosted web search disabled. Its + //! config file names that host for the launch region, and its credential comes from +@@ -492,10 +491,8 @@ pub fn provisioning_files( + /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// - /// The daemon writes every upload as root, so the `chown` is not optional and it runs --/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +-/// The daemon writes an upload as root unless the image sets `AGENTD_DEFAULT_USER`, so the +-/// `chown` is not optional and it runs as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming -/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user -/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call ++/// The daemon writes every upload as root, so the `chown` is not optional and it runs +/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is /// refreshed on a long-lived VM (AGENT-8). pub async fn install_access( -@@ -512,7 +510,6 @@ pub async fn install_access( +@@ -513,7 +510,6 @@ pub async fn install_access( vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], ) .with_shell(true) @@ -249,15 +947,15 @@ index e83657c..16a4ab8 100644 /// /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs -index 11db62c..f3d4bd5 100644 +index f6c4505..f3d4bd5 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,11 +367,8 @@ impl Session { .map(String::from) .chain(doomed) .collect(); -- // As root, by uid: the daemon wrote the tree as root, and an image's default user -- // would be refused deleting it. +- // As root, by uid: a deleted path can sit in a directory root owns, `/workspace` +- // itself among them, and an image's default user would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) - .with_user(Some(crate::session::ROOT_UID.into())) @@ -312,22 +1010,23 @@ index bf71c6a..ef6a544 100644 .with_group(spec.group) // No `with_timeout_sec`: the client-side deadline is the caller's `--timeout`, applied diff --git a/crates/protocol/src/exec.rs b/crates/protocol/src/exec.rs -index b572818..bba0754 100644 +index 748189c..bba0754 100644 --- a/crates/protocol/src/exec.rs +++ b/crates/protocol/src/exec.rs -@@ -232,9 +232,8 @@ pub struct StartRequest { +@@ -232,10 +232,8 @@ pub struct StartRequest { #[serde(default)] pub env: HashMap, /// The user to demote to: a numeric uid, or a name resolved against the guest's -- /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved as -- /// that string here would be, or the daemon's own user when the image sets none. A name -- /// the guest does not have answers 400 `unknown_user` before anything is spawned. +- /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved to +- /// the passwd row its name or uid matches, with that row's primary gid, or the daemon's +- /// own user when the image sets none. A name the guest does not have, or a default with +- /// no row, answers 400 `unknown_user` before anything is spawned. + /// `/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the + /// guest does not have answers 400 `unknown_user` before anything is spawned. /// /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME` /// from the row, beneath the launch environment and `env`. A user given by name also -@@ -551,8 +550,7 @@ impl StartRequest { +@@ -552,8 +550,7 @@ impl StartRequest { self } diff --git a/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch b/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch +++ b/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch b/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch +++ b/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch b/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch +++ b/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch b/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch +++ b/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch b/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch +++ b/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch b/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch index a0680e4e..6d0489dc 100644 --- a/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch +++ b/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch @@ -60,9 +60,10 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs +index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -419,17 +419,22 @@ +@@ -569,21 +569,26 @@ impl Confined { /// Creates and opens the file a data member's bytes go into. /// @@ -78,7 +79,7 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- self.replace(parent.as_fd(), &name); +- let previous = self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -88,11 +89,16 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; + // Through the descriptor `O_EXCL` created, which is this file whatever the name + // holds by now. +- self.own(fd.as_fd(), previous) ++ self.own(fd.as_fd(), None) + .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -526,8 +531,9 @@ - - /// Unlinks `name` under `dir`, ignoring every failure. +@@ -710,8 +715,9 @@ impl Confined { + /// was there when the extraction gives its entries an owner, so the new entry can keep + /// it. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -100,9 +106,9 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { -@@ -802,9 +808,6 @@ + /// create below fails and reports the real reason. The owner is read without following + /// a symlink, so a replaced link reports its own. +@@ -997,9 +1003,6 @@ fn extract_into( ), })?; } @@ -113,10 +119,10 @@ diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1e4b63c..b7dcc5a 100644 +index 1549ac9..f8b276f 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -124,37 +124,28 @@ pub async fn execute( +@@ -126,37 +126,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -174,7 +180,7 @@ index 1e4b63c..b7dcc5a 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -196,7 +202,7 @@ index 1e4b63c..b7dcc5a 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6e50bf2..126fbb5 100644 +index 6ea13ec..0af9290 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -315,10 +321,10 @@ index 6e50bf2..126fbb5 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index d06a170..3cab117 100644 +index 79f5411..6c4aef4 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -351,6 +357,7 @@ index d06a170..3cab117 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs +index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ From 7388f524bca8d700ccd1b0e5af8dcd74ad6219b8 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Mon, 12 Oct 2026 22:06:39 +0000 Subject: [PATCH 4/5] docs: the DS-0002 reason and the MMDS block name the default user #594 merged statements that an exec naming no user runs as root. With AGENTD_DEFAULT_USER set it runs as the image's user, so the trivy exception and TRUST.md's MMDS paragraph say when an exec runs as root: with user 0, or without user on an image that doesn't set the variable. --- .config/trivyignore.yaml | 3 ++- docs/TRUST.md | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.config/trivyignore.yaml b/.config/trivyignore.yaml index aac65de2..60db3be1 100644 --- a/.config/trivyignore.yaml +++ b/.config/trivyignore.yaml @@ -43,7 +43,8 @@ misconfigurations: (docs/TRUST.md), and the MicroVM, one tenant per Firecracker VM with its own kernel, is the isolation boundary for the host. DS-0002's goal, an unprivileged workload process, applies to agentd's execs: an exec runs - as the user its caller names, and as root when it names none. Revisit + as the user its caller names, as the image's AGENTD_DEFAULT_USER when + it names none and the image sets one, and as root otherwise. Revisit this exception if agentd stops needing to demote execs. The strands-box-codex-sdk example also needs root at start to give its sandbox a private mount namespace with a fresh /proc before diff --git a/docs/TRUST.md b/docs/TRUST.md index 1a8ffc9f..88ff3d63 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -273,12 +273,14 @@ instead of broadening the shared VM role. Blocking MMDS inside the guest takes `CAP_NET_ADMIN`. A guest from an image built without `--repair-identity` lacks it, and route, rule, and link changes -return `EPERM`. On a guest from an image built with it, root can replace the guest's host route for `169.254.169.254` -with a blackhole. Afterward, a process that isn't root can neither reach MMDS -nor remove the route ([Platform](PLATFORM.md), measured 2026-10-12). Any root -process can remove it, including an exec that doesn't ask for another user. The -block protects only workloads that run as another user. VPC internet isolation -does not remove metadata access or make an overprivileged role safe. +return `EPERM`. On a guest from an image built with it, root can replace the +guest's host route for `169.254.169.254` with a blackhole. Afterward, a process +that isn't root can neither reach MMDS nor remove the route +([Platform](PLATFORM.md), measured 2026-10-12). Any root process can remove it, +including an exec started with `user: 0`, and one started without `user` on an +image without `AGENTD_DEFAULT_USER`. The block protects only workloads that run as +another user. VPC internet isolation does not remove metadata access or make an +overprivileged role safe. ## Outside this contract From 7558adad1755f338cbd9b0810483ad796f3f6924 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Mon, 12 Oct 2026 22:34:15 +0000 Subject: [PATCH 5/5] revert(agentd): keep uploads root-owned under a default user (#595) d6a33c9 gave the default user what PUT /v1/fs/tar and PUT /v1/fs/file create. Review found that this hands the default user files outside the named tree. The owned walk follows a symlink that's already there with only RESOLVE_NO_MAGICLINKS. A workload running as the default user can swap a directory it owns from an earlier sync for a symlink to a directory root reads, and the next upload to that path extracts into the link's target and gives every new entry to the default user. The file route has the same hole twice: a planted symlink, and a race between the walk's descriptor and the reopen by path. This takes that half back out. fs.rs, schema.rs and agentd's Cargo.toml go back to main's versions, and so do the ten harden-agentd FAIL_TO_PASS patches rebased onto the owned fs.rs. The upload faults leave default-user.toml. Uploads don't read AGENTD_DEFAULT_USER again, so a default with no row no longer refuses one. The agent recipe's and the sync's comments go back to saying the daemon writes uploads as root. PROTOCOL.md's Upload ownership section, TRUST.md, the guest Dockerfile guide and the changelog now state the limit. Uploads stay root's: a tar's modes are masked to 0755 at most, and PUT /v1/fs/file creates its parents 0755 and its file 0644 unless the request sets a mode. So the default user can read an uploaded tree but can't write into it. An exec that must write there runs with user 0, or a root chown hands the tree over first. The guide no longer offers the variable as the default for every exec and `run --exec`. Safe upload ownership is its own issue. The numeric default's row and gid (Finding 1) and the changelog's client-version sentence (Finding 3) stay as they were. default_user_row is private again now that fs.rs doesn't call it. The two default-user FAIL_TO_PASS patches are regenerated against f410ec2. --- changelog.d/595.added.md | 19 +- crates/agentd/Cargo.toml | 7 +- crates/agentd/src/exec_start.rs | 15 +- crates/agentd/src/fs.rs | 674 ++---------------- crates/agentd/src/schema.rs | 30 +- crates/microvms-app/src/agents/mod.rs | 7 +- crates/microvms-app/src/workspace.rs | 4 +- docs/PROTOCOL.md | 36 +- docs/TRUST.md | 6 +- docs/schema.json | 16 +- .../operations/write-a-guest-dockerfile.md | 4 +- site/authored/learn/tutorial/long-lived-vm.md | 2 +- verify/guards/faults/default-user.toml | 70 +- ...eletion-path-is-refused-not-executed.patch | 669 +---------------- ...nk-preserves-its-targets-permissions.patch | 35 +- ...ections-past-the-capacity-are-closed.patch | 35 +- ...le-keep-alive-connections-are-closed.patch | 35 +- ...p-incomplete-headers-have-a-deadline.patch | 35 +- ...l-access-uploads-then-chowns-as-root.patch | 669 +---------------- ...nd-surviving-group-members-share-the.patch | 35 +- ...inishes-when-a-detached-writer-keeps.patch | 35 +- ...-drain-has-a-deadline-and-closes-the.patch | 35 +- ...-file-preserves-its-other-hard-links.patch | 35 +- ...-hook-body-collection-has-a-deadline.patch | 35 +- ...envelopes-are-bounded-before-parsing.patch | 35 +- 25 files changed, 296 insertions(+), 2282 deletions(-) diff --git a/changelog.d/595.added.md b/changelog.d/595.added.md index d07ff6bf..2fd506a0 100644 --- a/changelog.d/595.added.md +++ b/changelog.d/595.added.md @@ -5,13 +5,12 @@ created resolves on the next. The child gets that row's uid, primary gid, `HOME`, `USER` and `LOGNAME`, a uid included. A request's own `user` still runs as given, `root` and `0` included. If no row matches, digits included, each start without a `user` answers - `400 unknown_user`, and the detail mentions the variable. Uploads follow the same row: what - `PUT /v1/fs/tar` and `PUT /v1/fs/file` create, parent directories included, belongs to the - default user, so a nameless exec can write the tree `run . --exec` and `cp` upload, and what - already existed keeps its owner. A default with no row refuses an upload with the same - `400 unknown_user` before anything is written. With the variable unset, nothing changes. - Workload hook handlers still run as root. The agent recipe's `chown` of `/workspace` and a - sync's in-guest deletions now ask for uid 0 by number, so an image's default user can't make - them fail. An image that sets the variable needs a client at this release or later: an older - client sends that `chown` and those deletions with no `user`, so they run as the default user - and fail. + `400 unknown_user`, and the detail mentions the variable. Uploads don't follow the variable: + what `PUT /v1/fs/tar` and `PUT /v1/fs/file` create stays root's, so the default user can read + the tree `run . --exec` and `cp` upload but can't write into it. An exec that must write there + runs with `user: 0`, or the image or an exec with `user: 0` hands the tree to the default user + with `chown` first. With the variable unset, nothing changes. Workload hook handlers still run + as root. The agent recipe's `chown` of `/workspace` and a sync's in-guest deletions now ask + for uid 0 by number, so an image's default user can't make them fail. An image that sets the + variable needs a client at this release or later: an older client sends that `chown` and those + deletions with no `user`, so they run as the default user and fail. diff --git a/crates/agentd/Cargo.toml b/crates/agentd/Cargo.toml index ecb39369..9d15d2c0 100644 --- a/crates/agentd/Cargo.toml +++ b/crates/agentd/Cargo.toml @@ -56,11 +56,10 @@ tar = "0.4.46" subtle = "2.6" # Features, each earning its place: `signal` for the exec kill path, `fs` for # `statvfs` (the disk-pressure guard), `mount` for the `MS_BIND` that shadows the -# read-only procfs `boot_id`, `hostname` for `sethostname`, and `user` for the -# `fchownat` that gives an upload's new entries the image's default user. Pinned to -# the 0.31 line, which is what is already locked; nix is pre-1.0 and every minor is a +# read-only procfs `boot_id`, and `hostname` for `sethostname`. Pinned to the 0.31 +# line, which is what is already locked; nix is pre-1.0 and every minor is a # breaking change, so a caret on 0.31 is the widest safe range. -nix = { version = "0.31", features = ["signal", "fs", "mount", "hostname", "user"] } +nix = { version = "0.31", features = ["signal", "fs", "mount", "hostname"] } tempfile = "3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs index 453b4f54..3ddcca02 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs @@ -28,8 +28,7 @@ //! default doesn't keep the daemon's gid 0. A default with no row, digits included, is //! refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's //! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default -//! decides what a caller gets by not saying, not what a caller may ask for. The same row -//! owns what an upload creates (`fs.rs`). +//! decides what a caller gets by not saying, not what a caller may ask for. //! //! # The environment is five layers, lowest first //! @@ -268,12 +267,12 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, /// row of the uid its digits spell. /// /// Stricter than a request's `user`, where digits with no row run as a bare uid. The default -/// runs every start that names no user and owns every entry an upload creates, and a uid -/// with no row has no primary gid to give either, so the child would keep the daemon's gid -/// 0. A default with no row is refused instead (AGENTD-24). The error is the clause a -/// refusal's detail ends with, naming the value and the variable that set it, because the -/// caller sent no name to look for and the fix is in the image. -pub fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { +/// runs every start that names no user, and a uid with no row has no primary gid to give it, +/// so the child would keep the daemon's gid 0. A default with no row is refused instead +/// (AGENTD-24). The error is the clause a refusal's detail ends with, naming the value and +/// the variable that set it, because the caller sent no name to look for and the fix is in +/// the image. +fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { match resolve_user(&NameOrId::Name(name.to_string()), rows) { Ok(User { row: Some(row), .. }) => Ok(row), Ok(User { row: None, .. }) => Err(format!( diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs index 6844bcb7..c442ed62 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs @@ -65,25 +65,7 @@ //! resolution that would leave the root stops with `EXDEV`. Both become a 400 that //! names the member. The daemon no longer has to reason about whether a redirect is //! safe, because a redirected write cannot happen. -//! -//! # Who owns what an upload creates -//! -//! The daemon writes as root, so without `AGENTD_DEFAULT_USER` every entry an upload creates -//! is root's, as it always was. With it set, each entry `PUT /v1/fs/tar` or -//! `PUT /v1/fs/file` creates, parent directories included, is owned by the default user's -//! uid and primary gid, resolved by the rule a start that names no user uses -//! (`exec_start::default_user_row`), so the user a nameless exec runs as can write the tree -//! an upload made for it. An entry that was already there keeps its owner: an existing -//! directory, a file `PUT /v1/fs/file` truncates, and a name the archive replaces, whose new -//! entry takes the old one's owner. A hard link is a name for an inode that already exists, -//! so it changes no owner. A default with no passwd row refuses the upload with -//! `400 unknown_user` before anything is written. -//! -//! Each chown goes through a descriptor of the entry itself, never a path: the open file, -//! or a directory or symlink opened in its confined parent without following a symlink. A -//! name swapped between the create and the chown can't move the chown onto another file. -use std::borrow::Cow; use std::collections::HashMap; use std::ffi::{OsStr, OsString}; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -93,22 +75,19 @@ use std::path::{Component, Path, PathBuf}; use nix::errno::Errno; use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, OpenHow, ResolveFlag, openat2}; -use nix::sys::stat::{FchmodatFlags, Mode, SFlag, fchmodat, fstat, fstatat, mkdirat}; -use nix::unistd::{Gid, Uid, UnlinkatFlags, fchownat, linkat, symlinkat, unlinkat}; +use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; +use nix::unistd::{UnlinkatFlags, linkat, symlinkat, unlinkat}; -use axum::Json; use axum::body::Body; use axum::extract::{Request, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures_util::TryStreamExt; -use protocol::exec::{ERROR_UNKNOWN_USER, ErrorBody}; use tar::EntryType; use tokio_util::io::{ReaderStream, StreamReader}; use crate::disk::{self, CopyError}; -use crate::exec_start::{default_user_row, parse_passwd, read_database}; use crate::state::AppState; /// The two typed shapes in this module, re-exported from their original paths. The @@ -144,116 +123,6 @@ fn insufficient_storage(path: &Path, reading: disk::Reading) -> Response { .into_response() } -/// The owner of an entry an upload creates: the image's default user's uid and primary gid. -/// `None` in place of one, throughout this module, leaves an entry the daemon's, which is -/// root. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct Owner { - uid: u32, - gid: u32, -} - -impl Owner { - /// Gives the entry `fd` refers to this owner. `fd` may be `O_PATH`: with an empty path - /// and `AT_EMPTY_PATH`, `fchownat` changes the descriptor's own inode, a symlink's - /// included. - fn apply(self, fd: BorrowedFd<'_>) -> Result<(), Errno> { - fchownat( - fd, - "", - Some(Uid::from_raw(self.uid)), - Some(Gid::from_raw(self.gid)), - AtFlags::AT_EMPTY_PATH, - ) - } -} - -/// The owner of what this upload creates, or the detail of the `400 unknown_user` that -/// refuses it. -/// -/// Resolved at each upload from the guest's `/etc/passwd`, the way a start resolves the -/// default, so a user an earlier exec created owns the next upload. A default with no row -/// is refused here, before the body is read, because extracting it as root would leave -/// the default user a tree it can't write. The `Err` is the detail rather than a -/// `Response` so the variant stays small (clippy's `result_large_err`); -/// [`unknown_default_user`] builds the answer. -fn upload_owner(state: &AppState) -> Result, String> { - let config = state.config(); - let Some(name) = config.default_user.as_deref() else { - return Ok(None); - }; - let rows = parse_passwd(&read_database(&config.passwd_path)); - match default_user_row(name, &rows) { - Ok(row) => Ok(Some(Owner { - uid: row.uid, - gid: row.gid, - })), - Err(why) => { - let detail = format!("an upload's new entries belong to the default user, and {why}"); - Err(detail) - } - } -} - -/// The `400 unknown_user` that refuses an upload under a default with no passwd row. The -/// body is the exec routes' JSON `ErrorBody`, since the slug is theirs, where this module's -/// other refusals are plain text. -fn unknown_default_user(detail: String) -> Response { - tracing::warn!(detail, "upload refused"); - ( - StatusCode::BAD_REQUEST, - Json(ErrorBody { - error: Cow::Borrowed(ERROR_UNKNOWN_USER), - detail, - }), - ) - .into_response() -} - -/// Opens the directory `path`, creating each missing component as `create_dir_all` would, -/// and gives each component it creates to `owner`. -/// -/// Walked a component at a time from `/`, or from the working directory for a relative -/// path, each step relative to the descriptor of the last. A component that already exists -/// is followed wherever it leads, symlinks included, as `create_dir_all` follows them, -/// because the path is the caller's. A component this walk creates is opened with -/// [`Confined::RESOLVE`] and chowned through that descriptor, so a symlink put in its -/// place after the `mkdirat` stops the walk instead of taking the chown. -fn open_dirs_owned(path: &Path, owner: Owner) -> io::Result { - let open = |dir: BorrowedFd<'_>, name: &OsStr, resolve: ResolveFlag| { - let how = OpenHow::new().flags(Confined::DIRECTORY).resolve(resolve); - openat2(dir, name, how) - }; - let start = if path.is_absolute() { "/" } else { "." }; - let mut current = open( - AT_FDCWD, - OsStr::new(start), - ResolveFlag::RESOLVE_NO_MAGICLINKS, - )?; - for component in path.components() { - let name = match component { - Component::Normal(name) => name, - Component::ParentDir => OsStr::new(".."), - Component::RootDir | Component::CurDir | Component::Prefix(_) => continue, - }; - let created = match mkdirat(¤t, name, Mode::from_bits_truncate(0o777)) { - Ok(()) => true, - Err(Errno::EEXIST) => false, - Err(errno) => return Err(errno.into()), - }; - let resolve = if created { - Confined::RESOLVE - } else { - ResolveFlag::RESOLVE_NO_MAGICLINKS - }; - current = open(current.as_fd(), name, resolve)?; - if created { - owner.apply(current.as_fd())?; - } - } - Ok(current) -} - /// Why an archive or member was refused. /// /// The refused member's name travels with the refusal so the 400 can name it. A @@ -427,9 +296,6 @@ fn resolve_member(root: &Path, path: &Path) -> Landing { /// read from or written to even if the descriptor leaks somewhere it should not. struct Confined { root: OwnedFd, - /// Who owns each entry the extraction creates (see the module comment). `None` leaves - /// them the daemon's. - owner: Option, } impl Confined { @@ -463,19 +329,7 @@ impl Confined { .union(ResolveFlag::RESOLVE_NO_SYMLINKS) .union(ResolveFlag::RESOLVE_NO_MAGICLINKS); - /// The open flags of every directory on a walk: a path for `*at` calls only, which - /// fails unless the name is a directory. - const DIRECTORY: OFlag = OFlag::O_CLOEXEC - .union(OFlag::O_PATH) - .union(OFlag::O_DIRECTORY); - - /// The open flags of a symlink opened as itself, for its chown ([`Confined::open_link`]). - const LINK: OFlag = OFlag::O_CLOEXEC - .union(OFlag::O_PATH) - .union(OFlag::O_NOFOLLOW); - - /// Opens `root`, creating it if it is absent, and holds it. With an `owner`, each - /// directory created on the way to `root` is that owner's ([`open_dirs_owned`]). + /// Opens `root`, creating it if it is absent, and holds it. /// /// The root itself is opened by absolute path with only /// `RESOLVE_NO_MAGICLINKS`, not with [`Self::RESOLVE`]. `RESOLVE_BENEATH` @@ -493,18 +347,13 @@ impl Confined { /// and `File::create` this replaced: falling back would mean the confinement a /// caller cannot see silently became the weaker one, and issue #15 is what the /// weaker one allows. - fn open(root: &Path, owner: Option) -> io::Result { - let root = match owner { - Some(owner) => open_dirs_owned(root, owner)?, - None => { - std::fs::create_dir_all(root)?; - let how = OpenHow::new() - .flags(Self::DIRECTORY) - .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); - openat2(AT_FDCWD, root, how).map_err(io::Error::from)? - } - }; - Ok(Self { root, owner }) + fn open(root: &Path) -> io::Result { + std::fs::create_dir_all(root)?; + let how = OpenHow::new() + .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) + .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); + let root = openat2(AT_FDCWD, root, how).map_err(io::Error::from)?; + Ok(Self { root }) } /// Opens the single component `name` under `dir` as a directory. @@ -514,7 +363,9 @@ impl Confined { /// the error name which component failed, and a 400 that names the component /// is the difference between an operator fixing their tree and re-reading it. fn open_dir(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Result { - let how = OpenHow::new().flags(Self::DIRECTORY).resolve(Self::RESOLVE); + let how = OpenHow::new() + .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) + .resolve(Self::RESOLVE); openat2(dir, name, how) } @@ -524,8 +375,7 @@ impl Confined { /// not an error: an archive may name the same directory twice, and a partial /// extraction that is retried has to converge. The open that follows decides /// whether the existing name is usable, and a symlink there fails with - /// `ELOOP` rather than being traversed. A directory this walk creates is given the - /// extraction's owner through that descriptor; one that existed keeps its own. + /// `ELOOP` rather than being traversed. fn make_dirs(&self, parts: &[OsString]) -> Result { let mut current = self.root.try_clone().map_err(|err| WalkError { component: None, @@ -536,33 +386,17 @@ impl Confined { // replayed by `apply_deferred_modes` after every member has landed. // Applying it here is the defect that pass exists to avoid: a 0o500 // directory blocks the writes of its own children. - let created = match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) - { - Ok(()) => true, - Err(Errno::EEXIST) => false, + match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) { + Ok(()) | Err(Errno::EEXIST) => {} Err(errno) => return Err(WalkError::at(parts, index, errno)), - }; + } current = self .open_dir(current.as_fd(), part) .map_err(|errno| WalkError::at(parts, index, errno))?; - if created { - self.own(current.as_fd(), None) - .map_err(|errno| WalkError::at(parts, index, errno))?; - } } Ok(current) } - /// Gives an entry this extraction created its owner: `previous`, the owner of the entry - /// it replaced, when there was one, and the extraction's otherwise. Nothing without an - /// extraction owner, so an unset default leaves every entry the daemon's. - fn own(&self, fd: BorrowedFd<'_>, previous: Option) -> Result<(), Errno> { - match self.owner { - Some(owner) => previous.unwrap_or(owner).apply(fd), - None => Ok(()), - } - } - /// Splits `parts` into the parent directory descriptor and the final name. /// /// Every leaf creation needs exactly this: a `dirfd` the kernel has already @@ -594,13 +428,9 @@ impl Confined { fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; - let previous = self.replace(parent.as_fd(), &name); + self.replace(parent.as_fd(), &name); let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. - self.own(fd.as_fd(), previous) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } @@ -628,34 +458,9 @@ impl Confined { fn create_symlink(&self, parts: &[OsString], target: &Path) -> Result<(), WalkError> { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; - let previous = self.replace(parent.as_fd(), &name); + self.replace(parent.as_fd(), &name); symlinkat(target, parent.as_fd(), name.as_os_str()) - .map_err(|errno| WalkError::at(parts, last, errno))?; - if self.owner.is_some() { - let link = Self::open_link(parent.as_fd(), &name) - .map_err(|errno| WalkError::at(parts, last, errno))?; - self.own(link.as_fd(), previous) - .map_err(|errno| WalkError::at(parts, last, errno))?; - } - Ok(()) - } - - /// Opens the symlink `name` under `dir` itself, `O_PATH` and without following it, for - /// its chown. - /// - /// The name is opened rather than chowned in place, and the result has to be a - /// symlink, because a process that can write `dir` can swap the link for a hard link - /// to another file between the `symlinkat` and the chown, and a chown by name would then - /// hand that file to the default user. `EEXIST` says the name no longer holds the link. - fn open_link(dir: BorrowedFd<'_>, name: &OsStr) -> Result { - let how = OpenHow::new().flags(Self::LINK).resolve(Self::RESOLVE); - let link = openat2(dir, name, how)?; - let kind = SFlag::from_bits_truncate(fstat(&link)?.st_mode) & SFlag::S_IFMT; - if kind == SFlag::S_IFLNK { - Ok(link) - } else { - Err(Errno::EEXIST) - } + .map_err(|errno| WalkError::at(parts, last, errno)) } /// Creates a hard link at `parts` pointing at `source`, an archive-root-relative @@ -674,7 +479,6 @@ impl Confined { ) -> Result<(), HardLinkError> { let (parent, name) = self.parent_of(parts).map_err(HardLinkError::Dest)?; let last = parts.len() - 1; - // No chown: the link names an inode that already exists, whose owner it keeps. self.replace(parent.as_fd(), &name); // An empty source is the archive naming the root as a hard-link target. @@ -720,25 +524,14 @@ impl Confined { Ok(current) } - /// Unlinks `name` under `dir`, ignoring every failure, and returns the owner of what - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. + /// Unlinks `name` under `dir`, ignoring every failure. /// /// A file or link member replaces the directory entry before creation, so /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. - fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Option { - let previous = self - .owner - .and_then(|_| fstatat(dir, name, AtFlags::AT_SYMLINK_NOFOLLOW).ok()) - .map(|stat| Owner { - uid: stat.st_uid, - gid: stat.st_gid, - }); + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { let _ = unlinkat(dir, name, UnlinkatFlags::NoRemoveDir); - previous } /// Applies `mode` to `parts`, without following a symlink at the last component. @@ -853,11 +646,10 @@ pub fn fuzz_extract( probe: disk::available_bytes, reserve: 0, }; - extract_into(root, archive, caps, &guard, None).map_err(|refusal| format!("{refusal:?}")) + extract_into(root, archive, caps, &guard).map_err(|refusal| format!("{refusal:?}")) } -/// Extracts `archive` into `root`, enforcing the data-filter contract, with each entry it -/// creates given to `owner` when there is one (see the module comment). +/// Extracts `archive` into `root`, enforcing the data-filter contract. /// /// Synchronous on purpose: `tar`'s reader is blocking, so callers hand this to /// `spawn_blocking` rather than pretending it is async. @@ -866,18 +658,17 @@ fn extract_into( archive: impl Read, caps: Caps, guard: &disk::Guard, - owner: Option, ) -> Result { // The kernel's half of the confinement, held for the whole extraction. Every // member below is created through this rather than by absolute path, so a // component that turns out to be a symlink stops the write instead of // redirecting it. See the module comment. - let confined = Confined::open(root, owner)?; + let confined = Confined::open(root)?; let mut ar = tar::Archive::new(archive); - // The archive's ownership is dropped for the same reason CPython's `data` filter - // drops it: its uid/gid are the packing host's, meaningless here, and honoring them - // would need privilege we should not spend. Who owns an entry is `owner`'s to say. + // Ownership is dropped for the same reason CPython's `data` filter drops it: + // the archive's uid/gid are the packing host's, meaningless here, and honoring + // them would need privilege we should not spend. ar.set_preserve_permissions(false); ar.set_preserve_ownerships(false); ar.set_unpack_xattrs(false); @@ -1404,8 +1195,7 @@ fn sliced_stream( }) } -/// Writes one file. Not confined to a root; see the module comment, which also says who -/// owns the file and the parents this creates. +/// Writes one file. Not confined to a root; see the module comment. pub async fn write_file(State(state): State, request: Request) -> Response { let query = match fs_query(&request) { Ok(query) => query, @@ -1432,13 +1222,6 @@ pub async fn write_file(State(state): State, request: Request) -> Resp }, }; - // Like the mode, before anything lands: a default user with no passwd row refuses the - // write rather than leaving it a root-owned file. - let owner = match upload_owner(&state) { - Ok(owner) => owner, - Err(detail) => return unknown_default_user(detail), - }; - // Checked before the parent directory is created and before the file is // opened, so a refused write leaves nothing behind — not even an empty file // where the caller's data was supposed to go. An empty file at the target path @@ -1447,61 +1230,24 @@ pub async fn write_file(State(state): State, request: Request) -> Resp return insufficient_storage(&path, reading); } - if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { - let created = match owner { - None => tokio::fs::create_dir_all(parent).await, - Some(owner) => { - let parent = parent.to_path_buf(); - tokio::task::spawn_blocking(move || open_dirs_owned(&parent, owner).map(drop)) - .await - .unwrap_or_else(|err| Err(io::Error::other(err))) - } - }; - if let Err(err) = created { - tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); - return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); - } + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) + && let Err(err) = tokio::fs::create_dir_all(parent).await + { + tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); + return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); } - let options = |create_new: bool| { - let mut options = tokio::fs::OpenOptions::new(); - options.write(true).truncate(true); - if create_new { - options.create_new(true); - } else { - options.create(true); - } - if let Some(mode) = mode { - // Set at open, so the file never exists with a wider mode than asked for - // even briefly. A separate chmod after writing leaves that window open, - // which matters when the file is a credential. - options.mode(mode); - } - options - }; - // With an owner to give, `O_EXCL` first, so the daemon knows whether this write - // created the file: one it only truncates keeps its owner. - let opened = match owner { - None => options(false).open(&path).await.map(|file| (file, false)), - // The usual failure is that the file exists, which the plain open truncates; any - // other, the plain open meets again and reports. - Some(_) => match options(true).open(&path).await { - Ok(file) => Ok((file, true)), - Err(_) => options(false).open(&path).await.map(|file| (file, false)), - }, - }; - let mut file = match opened { - Ok((file, created)) => { - if let (true, Some(owner)) = (created, owner) - && let Err(errno) = owner.apply(file.as_fd()) - { - // The file is new and empty, and nobody was told it exists. - let _ = tokio::fs::remove_file(&path).await; - tracing::warn!(path = %path.display(), %errno, "cannot give the file its owner"); - return (StatusCode::INTERNAL_SERVER_ERROR, "cannot set owner").into_response(); - } - file - } + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + if let Some(mode) = mode { + // Set at open, so the file never exists with a wider mode than asked for + // even briefly. A separate chmod after writing leaves that window open, + // which matters when the file is a credential. + options.mode(mode); + } + + let mut file = match options.open(&path).await { + Ok(file) => file, Err(err) => { tracing::warn!(path = %path.display(), %err, "cannot open file for write"); return (StatusCode::INTERNAL_SERVER_ERROR, "cannot write file").into_response(); @@ -1718,13 +1464,6 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo .into_response(); } - // Before the body is spooled, so a default user with no passwd row refuses the upload - // without receiving it and before any member lands. - let owner = match upload_owner(&state) { - Ok(owner) => owner, - Err(detail) => return unknown_default_user(detail), - }; - let caps = Caps { max_members: state.config().max_tar_members, max_bytes: state.config().max_tar_bytes, @@ -1755,7 +1494,7 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo }; let extracted = tokio::task::spawn_blocking(move || { - extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n)) + extract_into(&root, spool, caps, &guard).map(|n| (root, n)) }) .await; @@ -1961,7 +1700,7 @@ mod tests { ) -> (TempDir, Result) { let dir = TempDir::new().expect("tempdir"); let root = dir.path().join("root"); - let result = extract_into(&root, archive.as_slice(), caps, &guard, None); + let result = extract_into(&root, archive.as_slice(), caps, &guard); (dir, result) } @@ -1986,7 +1725,7 @@ mod tests { std::fs::hard_link(&original, root.join("member")).expect("hard link"); let archive = Archive::new().file("member", b"replacement bytes").bytes(); assert_eq!( - extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), None).unwrap(), + extract_into(&root, archive.as_slice(), OPEN, &roomy_guard()).unwrap(), 1 ); assert_eq!(std::fs::read(&original).unwrap(), b"original bytes"); @@ -2017,7 +1756,7 @@ mod tests { .link(EntryType::Link, "member", "target") .bytes(); assert_eq!( - extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), None).unwrap(), + extract_into(&root, archive.as_slice(), OPEN, &roomy_guard()).unwrap(), 2 ); assert_eq!( @@ -2256,7 +1995,7 @@ mod tests { // /tmp would leave nothing to find and would read as a pass. let dir = TempDir::new().expect("tempdir"); let root = dir.path().join("nested/deeper/root"); - let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), None); + let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard()); // 400 naming the member, not a 500. The property tier asserts that every // refusal names a member the archive contained, and the member's name here is @@ -2959,7 +2698,7 @@ mod tests { fn a_new_archive_file_refuses_an_occupied_name() { let dir = TempDir::new().expect("tempdir"); std::fs::write(dir.path().join("occupied"), b"original").expect("existing file"); - let confined = Confined::open(dir.path(), None).expect("root"); + let confined = Confined::open(dir.path()).expect("root"); assert_eq!( Confined::open_new_file(confined.root.as_fd(), OsStr::new("occupied")) .expect_err("an occupied name is refused"), @@ -2984,7 +2723,7 @@ mod tests { use nix::fcntl::{FcntlArg, FdFlag, fcntl}; let dir = TempDir::new().expect("tempdir"); - let confined = Confined::open(dir.path(), None).expect("root"); + let confined = Confined::open(dir.path()).expect("root"); let fd = Confined::open_new_file(confined.root.as_fd(), OsStr::new("new")) .expect("a new file opens"); let descriptor_flags = FdFlag::from_bits_retain(fcntl(&fd, FcntlArg::F_GETFD).unwrap()); @@ -2993,311 +2732,4 @@ mod tests { file.write_all(b"contents").expect("the file is writable"); assert_eq!(std::fs::read(dir.path().join("new")).unwrap(), b"contents"); } - - // ── ownership under AGENTD_DEFAULT_USER ───────────────────────────────── - - /// The uid and gid of this test process, which it can always chown an entry to. - fn own_owner() -> Owner { - Owner { - uid: nix::unistd::getuid().as_raw(), - gid: nix::unistd::getgid().as_raw(), - } - } - - /// An owner this unprivileged test can't give an entry: root's. A chown to it is - /// `EPERM`, so an extraction that chowns an entry fails, and one that leaves every entry - /// alone succeeds. These tests run unprivileged, like the exec tests that name uid 65534. - const ROOT: Owner = Owner { uid: 0, gid: 0 }; - - fn is_eperm(result: &Result) -> bool { - matches!(result, Err(Refusal::Io(err)) if err.raw_os_error() == Some(Errno::EPERM as i32)) - } - - /// Extracts `archive` into an existing root holding nothing, with `owner`. - fn extract_owned(archive: Vec, owner: Owner) -> (TempDir, Result) { - let dir = TempDir::new().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir(&root).expect("root"); - let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), Some(owner)); - (dir, result) - } - - /// Each kind of entry an archive creates is chowned: a file, a directory member, a - /// parent directory the archive only implies, and a symlink, each alone in an existing - /// root, and the directories on the way to a root that doesn't exist yet. With an owner - /// the process may give, the same archives land. - #[test] - fn an_upload_gives_each_entry_it_creates_the_owner() { - use std::os::unix::fs::MetadataExt; - let archives = [ - ("a file", Archive::new().file("f", b"x").bytes()), - ("a directory", Archive::new().dir("d", 0o755).bytes()), - ( - "an implied parent", - Archive::new().file("p/f", b"x").bytes(), - ), - ( - "a symlink", - Archive::new().link(EntryType::Symlink, "s", "f").bytes(), - ), - ]; - for (kind, archive) in archives { - let (_dir, result) = extract_owned(archive.clone(), ROOT); - assert!(is_eperm(&result), "{kind} wasn't chowned: {result:?}"); - let (dir, result) = extract_owned(archive, own_owner()); - assert!(result.is_ok(), "{kind}: {result:?}"); - for entry in std::fs::read_dir(dir.path().join("root")).expect("root") { - let meta = entry.expect("entry").metadata().expect("lstat"); - assert_eq!((meta.uid(), meta.gid()), (own_owner().uid, own_owner().gid)); - } - } - - let dir = TempDir::new().expect("tempdir"); - let root = dir.path().join("a/b/root"); - let empty = Archive::new().bytes(); - let result = extract_into(&root, empty.as_slice(), OPEN, &roomy_guard(), Some(ROOT)); - assert!( - is_eperm(&result), - "the root's new parents weren't chowned: {result:?}" - ); - let result = extract_into( - &root, - empty.as_slice(), - OPEN, - &roomy_guard(), - Some(own_owner()), - ); - assert!(result.is_ok() && root.is_dir(), "{result:?}"); - let result = extract_into(&root, empty.as_slice(), OPEN, &roomy_guard(), Some(ROOT)); - assert!( - result.is_ok(), - "an existing root keeps its owner: {result:?}" - ); - } - - /// What was already there keeps its owner: an existing directory isn't chowned, a file - /// or symlink the archive replaces takes the owner of the entry it replaced, and a hard - /// link changes no inode's owner. Under an owner this process can't give, the archive - /// lands only if none of them is chowned to it. - #[test] - fn an_upload_leaves_what_already_existed_its_owner() { - let dir = TempDir::new().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir_all(root.join("d")).expect("dirs"); - std::fs::write(root.join("d/f"), b"old").expect("file"); - std::os::unix::fs::symlink("old-target", root.join("l")).expect("symlink"); - let archive = Archive::new() - .dir("d", 0o755) - .file("d/f", b"new") - .link(EntryType::Symlink, "l", "d/f") - .link(EntryType::Link, "h", "d/f") - .bytes(); - let result = extract_into(&root, archive.as_slice(), OPEN, &roomy_guard(), Some(ROOT)); - assert_eq!(result.expect("nothing was chowned to root"), 4); - assert_eq!(std::fs::read(root.join("l")).expect("link"), b"new"); - assert_eq!(std::fs::read(root.join("h")).expect("hard link"), b"new"); - } - - /// A symlink the kernel won't create is refused naming the link, the member's last - /// component. This test runs unprivileged: root writes through a 0o555 directory. - #[test] - fn a_symlink_its_directory_refuses_is_refused_naming_the_link() { - use std::os::unix::fs::PermissionsExt; - let dir = TempDir::new().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir_all(root.join("ro")).expect("dirs"); - std::fs::set_permissions(root.join("ro"), std::fs::Permissions::from_mode(0o555)) - .expect("read-only"); - let archive = Archive::new().link(EntryType::Symlink, "ro/s", "x").bytes(); - let reason = refusal_reason(extract_into( - &root, - archive.as_slice(), - OPEN, - &roomy_guard(), - None, - )); - assert!( - reason.contains("cannot create under s: directory permissions"), - "{reason}" - ); - } - - /// A symlink is chowned through a descriptor of the link itself, and a name that holds - /// anything else by the time it's opened is refused, so a hard link swapped in for the - /// link can't take the chown. - #[test] - fn a_symlinks_chown_opens_the_link_itself() { - let dir = TempDir::new().expect("tempdir"); - std::fs::write(dir.path().join("plain"), b"x").expect("file"); - std::os::unix::fs::symlink("plain", dir.path().join("link")).expect("symlink"); - let confined = Confined::open(dir.path(), None).expect("root"); - let link = Confined::open_link(confined.root.as_fd(), OsStr::new("link")).expect("a link"); - let kind = SFlag::from_bits_truncate(fstat(&link).expect("fstat").st_mode) & SFlag::S_IFMT; - assert_eq!(kind, SFlag::S_IFLNK); - assert_eq!( - Confined::open_link(confined.root.as_fd(), OsStr::new("plain")) - .expect_err("a file isn't the link"), - Errno::EEXIST - ); - } - - /// A root reached through a symlink the caller named still opens: the components that - /// already exist are followed, as `create_dir_all` follows them, and only the ones the - /// walk creates are opened without following one. - #[test] - fn an_owned_root_follows_the_callers_symlinks() { - let dir = TempDir::new().expect("tempdir"); - std::fs::create_dir(dir.path().join("real")).expect("dir"); - std::os::unix::fs::symlink("real", dir.path().join("via")).expect("symlink"); - let root = dir.path().join("via/root"); - let archive = Archive::new().file("f", b"x").bytes(); - let result = extract_into( - &root, - archive.as_slice(), - OPEN, - &roomy_guard(), - Some(own_owner()), - ); - assert_eq!(result.expect("extracts"), 1); - assert!(dir.path().join("real/root/f").is_file()); - } - - /// A daemon whose image sets `AGENTD_DEFAULT_USER` to `default`, over a passwd that lists - /// root and `me`, this process's own uid and gid. - fn defaulted_state(dir: &TempDir, default: &str) -> AppState { - let passwd = dir.path().join("passwd"); - let me = own_owner(); - std::fs::write( - &passwd, - format!( - "root:x:0:0::/root:/bin/sh\nme:x:{}:{}::/home/me:/bin/sh\n", - me.uid, me.gid - ), - ) - .expect("passwd"); - AppState::with_probe( - Config { - passwd_path: passwd, - default_user: Some(default.into()), - ..Config::default() - }, - roomy_probe, - crate::identity::no_repair(), - ) - } - - fn put(route: &str, path: &Path, body: Vec) -> Request { - axum::http::Request::builder() - .method("PUT") - .uri(format!("/v1/fs/{route}?path={}", path.display())) - .body(Body::from(body)) - .expect("request") - } - - async fn error_body(response: Response) -> serde_json::Value { - let body = axum::body::to_bytes(response.into_body(), 64 * 1024) - .await - .expect("body"); - serde_json::from_slice(&body).expect("a JSON error body") - } - - /// A default user with no passwd row, a name or digits, refuses a tar upload and a file - /// write as `400 unknown_user` naming the variable, as it refuses a start, and before - /// anything is written: not the root, not a parent, not the file. - #[tokio::test] - async fn an_upload_under_a_default_with_no_row_is_refused_before_anything_lands() { - for default in ["ghost", "4242"] { - let dir = TempDir::new().expect("tempdir"); - let state = defaulted_state(&dir, default); - let root = dir.path().join("tree/root"); - let archive = Archive::new().file("f", b"x").bytes(); - let response = write_tar(State(state.clone()), put("tar", &root, archive)).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = error_body(response).await; - assert_eq!(body["error"], ERROR_UNKNOWN_USER, "{body}"); - let detail = body["detail"].as_str().expect("detail"); - assert!( - detail.contains(&format!("{default:?}")) && detail.contains("AGENTD_DEFAULT_USER"), - "{detail}" - ); - assert_eq!( - detail.contains("needs a passwd row"), - default == "4242", - "{detail}" - ); - - let file = dir.path().join("file/new.txt"); - let response = write_file(State(state), put("file", &file, b"x".to_vec())).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(error_body(response).await["error"], ERROR_UNKNOWN_USER); - assert!(!dir.path().join("tree").exists() && !dir.path().join("file").exists()); - } - } - - /// The handlers resolve the default to its row and hand that owner to the write: under - /// `root`, by name or as `0`, a new entry's chown is the `EPERM` this unprivileged test - /// expects, and under its own row everything lands. - #[tokio::test] - async fn the_routes_give_new_entries_the_default_users_row() { - for default in ["root", "0"] { - let dir = TempDir::new().expect("tempdir"); - let state = defaulted_state(&dir, default); - let archive = Archive::new().file("f", b"x").bytes(); - let response = write_tar( - State(state.clone()), - put("tar", &dir.path().join("t"), archive), - ) - .await; - assert_eq!( - response.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "{default}" - ); - let response = write_file( - State(state), - put("file", &dir.path().join("p/f"), b"x".to_vec()), - ) - .await; - assert_eq!( - response.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "{default}" - ); - } - - let dir = TempDir::new().expect("tempdir"); - let state = defaulted_state(&dir, "me"); - let archive = Archive::new().file("sub/f", b"tar").bytes(); - let root = dir.path().join("t"); - let response = write_tar(State(state.clone()), put("tar", &root, archive)).await; - assert_eq!(response.status(), StatusCode::NO_CONTENT); - assert_eq!( - std::fs::read(root.join("sub/f")).expect("extracted"), - b"tar" - ); - let file = dir.path().join("p/q/f"); - let response = write_file(State(state), put("file", &file, b"file".to_vec())).await; - assert_eq!(response.status(), StatusCode::NO_CONTENT); - assert_eq!(std::fs::read(&file).expect("written"), b"file"); - } - - /// A file write chowns only what it creates. A new file it can't give the owner is - /// removed and answered 500; an existing file is truncated in place and keeps its owner. - #[tokio::test] - async fn a_file_write_gives_the_owner_only_a_file_it_creates() { - let dir = TempDir::new().expect("tempdir"); - let state = defaulted_state(&dir, "root"); - let file = dir.path().join("f"); - let response = write_file(State(state.clone()), put("file", &file, b"x".to_vec())).await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!( - !file.exists(), - "a file that couldn't take its owner is removed" - ); - - std::fs::write(&file, b"old").expect("existing file"); - let response = write_file(State(state), put("file", &file, b"new".to_vec())).await; - assert_eq!(response.status(), StatusCode::NO_CONTENT); - assert_eq!(std::fs::read(&file).expect("written"), b"new"); - } } diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs index 79f54117..eccaf3c9 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs @@ -602,12 +602,7 @@ pub const FS_READ_FILE: &[Status] = &[ ]; pub const FS_WRITE_FILE: &[Status] = &[ - Status::new( - StatusCode::NO_CONTENT, - "", - "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or \ - parent directory the write creates belongs to the default user.", - ), + Status::new(StatusCode::NO_CONTENT, "", "written, with the mode applied"), Status::new( StatusCode::BAD_REQUEST, "", @@ -615,7 +610,6 @@ pub const FS_WRITE_FILE: &[Status] = &[ mode. Checked before a byte lands, so a rejected mode leaves nothing \ behind.", ), - UPLOAD_UNKNOWN_USER, UNAUTHORIZED, NOT_BOOTSTRAPPED, BODY_TOO_LARGE, @@ -623,8 +617,7 @@ pub const FS_WRITE_FILE: &[Status] = &[ Status::new( StatusCode::INTERNAL_SERVER_ERROR, "", - "the parent could not be created, the write or chmod failed, or a file the \ - write created could not be given the default user, in which case it is removed", + "the parent could not be created, or the write or chmod failed", ), ]; @@ -661,24 +654,8 @@ pub const FS_READ_TAR: &[Status] = &[ ), ]; -/// The refusal both upload routes share when the image's default user has no passwd row. -/// Unlike the routes' other refusals, its body is the JSON `ErrorBody`, since the slug is -/// the one a start answers. -const UPLOAD_UNKNOWN_USER: Status = Status::new( - StatusCode::BAD_REQUEST, - "unknown_user", - "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so \ - nothing the upload creates could belong to it. Checked before the body is read; \ - nothing was written, and detail names the value and the variable.", -); - pub const FS_WRITE_TAR: &[Status] = &[ - Status::new( - StatusCode::NO_CONTENT, - "", - "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction \ - creates belongs to the default user.", - ), + Status::new(StatusCode::NO_CONTENT, "", "extracted under ?path="), Status::new( StatusCode::BAD_REQUEST, "", @@ -687,7 +664,6 @@ pub const FS_WRITE_TAR: &[Status] = &[ path, an absolute or out-of-tree link target, or a device or fifo member. \ The refused member's name is in the body.", ), - UPLOAD_UNKNOWN_USER, UNAUTHORIZED, NOT_BOOTSTRAPPED, Status::new( diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs index f7ca2089..333dfae5 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs @@ -16,8 +16,7 @@ //! 2. **The daemon's execs start from an empty environment**, so the agent's subshells //! find no `PATH` and every command exits 127. The environment file sets it. //! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent -//! cannot read, unless the image sets `AGENTD_DEFAULT_USER`. One root `chown` follows the -//! uploads either way. +//! cannot read. One root `chown` follows the uploads. //! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses //! wire API, with an inference-profile model id and hosted web search disabled. Its //! config file names that host for the launch region, and its credential comes from @@ -492,8 +491,8 @@ pub fn provisioning_files( /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// -/// The daemon writes an upload as root unless the image sets `AGENTD_DEFAULT_USER`, so the -/// `chown` is not optional and it runs as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming +/// The daemon writes every upload as root, so the `chown` is not optional and it runs +/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming /// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user /// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs index f6c4505a..11db62c7 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,8 +367,8 @@ impl Session { .map(String::from) .chain(doomed) .collect(); - // As root, by uid: a deleted path can sit in a directory root owns, `/workspace` - // itself among them, and an image's default user would be refused deleting it. + // As root, by uid: the daemon wrote the tree as root, and an image's default user + // would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) .with_user(Some(crate::session::ROOT_UID.into())) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9ee3ad6f..f6c9d08b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -212,8 +212,8 @@ semantics rather than `realpath`. Symlinks resolve relative to their own directory, while hard links resolve against the archive root. Member count and total size are capped. Modes are applied after content lands. A regular-file member replaces the old entry before creation, so other hard links keep their -bytes and modes. Extraction drops the archive's owners, as -[Upload ownership](#upload-ownership) describes. +bytes and modes. Extraction drops the archive's owners, so everything it +creates is root's, as [Upload ownership](#upload-ownership) describes. **A symlink an archive wrote cannot redirect a later member, and the kernel is what enforces that.** The `normpath` rule above judges a member at the depth its @@ -355,7 +355,7 @@ each start that omits `user` with `400 unknown_user`, and the `detail` mentions passwd row for that uid. The daemon refuses an empty value the same way. It still boots, and a request that sets `user` still runs. With the variable unset, a start that omits `user` runs as the daemon's own user, root, as it always has. -What an upload creates belongs to the same row, as +Uploads don't follow the variable: what they create stays root's, as [Upload ownership](#upload-ownership) describes. Workload hook handlers ignore the variable, and the image snapshot drops it with every other `AGENTD_*` variable. `/v1/health` doesn't report it, because a new @@ -412,22 +412,20 @@ configuration reaches one. ## Upload ownership -**With `AGENTD_DEFAULT_USER` set, what an upload creates belongs to the image's -default user.** `PUT /v1/fs/tar` and `PUT /v1/fs/file` give each entry they -create the uid and primary gid of the row a start without `user` resolves to. -That covers files, directories, symlinks, and the parent directories the daemon -makes for them, so a nameless exec can write the tree an upload made for it. An -entry that was already there keeps its owner: an existing directory, the -extraction root among them, and a file `PUT /v1/fs/file` truncates. When an -archive member replaces an existing file or symlink, the new entry takes the old -one's owner, and a hard link doesn't change an owner. The daemon -changes each owner through a descriptor of the entry itself, opened without -following a symlink, never by path, so a name swapped mid-upload can't redirect -it. A failed change of owner answers 500. With the variable unset, every entry -an upload creates is root's, as it always was. If the default has no passwd -row, the daemon refuses the upload before writing anything, with the start's -`400 unknown_user`. That body is the JSON `{error, detail}` of the exec routes, -where the other fs refusals are plain text. +**Uploads are root's, whatever `AGENTD_DEFAULT_USER` holds.** `PUT /v1/fs/tar` +and `PUT /v1/fs/file` write as the daemon's user, root, so each entry they +create belongs to root: files, directories, symlinks, and the parent +directories made for them. Tar extraction masks each member's mode to `0755` at +most, so it doesn't set a group or other write bit on a file or directory. +`PUT /v1/fs/file` gives the file the `mode` the request sets, or `0644` without +one, and the parent directories it creates are `0755`. The image's default user +can read an uploaded tree, then, but can't write into the directories an upload +created, or change a file unless its requested `mode` lets other users write. +An exec that must write there runs with +`user: 0`, or the image or an exec started with `user: 0` hands the tree to the +default user first with `chown`. The agent recipe's `chown` of `/workspace` +does that. A default with no passwd row doesn't refuse an upload, because an +upload doesn't read the variable. ## Line-ranged text reads diff --git a/docs/TRUST.md b/docs/TRUST.md index 88ff3d63..9a365f1e 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -23,9 +23,9 @@ A start request that omits `user` runs as root unless the image sets `AGENTD_DEFAULT_USER`. With it set, that start runs as the image's user, with its passwd row's uid and primary gid, whether the image gives the user's name or its uid. A value with no passwd row, digits included, refuses the start -instead of running it as root or in root's group. What an upload creates -belongs to that user too, so the workload can rewrite it. An authorized caller -can still execute as root by asking for `root` or `0`, and workload hook +instead of running it as root or in root's group. Uploads are still written as +root, so that user can read an uploaded tree but can't write into it. An authorized +caller can still execute as root by asking for `root` or `0`, and workload hook handlers still run as root. ## Platform isolation guarantees diff --git a/docs/schema.json b/docs/schema.json index c69accef..67796dbc 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -1392,18 +1392,13 @@ { "code": 204, "error": "", - "meaning": "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or parent directory the write creates belongs to the default user." + "meaning": "written, with the mode applied" }, { "code": 400, "error": "", "meaning": "the path query parameter is missing, or mode is not a valid octal file mode. Checked before a byte lands, so a rejected mode leaves nothing behind." }, - { - "code": 400, - "error": "unknown_user", - "meaning": "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so nothing the upload creates could belong to it. Checked before the body is read; nothing was written, and detail names the value and the variable." - }, { "code": 401, "error": "", @@ -1427,7 +1422,7 @@ { "code": 500, "error": "", - "meaning": "the parent could not be created, the write or chmod failed, or a file the write created could not be given the default user, in which case it is removed" + "meaning": "the parent could not be created, or the write or chmod failed" } ], "summary": "write one file. Deliberately not confined to a root: the same token authorizes exec, so a root prefix would add no security while breaking harnesses that write to home directories and /etc." @@ -1505,18 +1500,13 @@ { "code": 204, "error": "", - "meaning": "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction creates belongs to the default user." + "meaning": "extracted under ?path=" }, { "code": 400, "error": "", "meaning": "the path query parameter is missing or not absolute, the body was truncated, or a member violated the extraction contract — an escaping path, an absolute or out-of-tree link target, or a device or fifo member. The refused member's name is in the body." }, - { - "code": 400, - "error": "unknown_user", - "meaning": "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so nothing the upload creates could belong to it. Checked before the body is read; nothing was written, and detail names the value and the variable." - }, { "code": 401, "error": "", diff --git a/site/authored/learn/operations/write-a-guest-dockerfile.md b/site/authored/learn/operations/write-a-guest-dockerfile.md index 3426d065..9ba7ad63 100644 --- a/site/authored/learn/operations/write-a-guest-dockerfile.md +++ b/site/authored/learn/operations/write-a-guest-dockerfile.md @@ -71,7 +71,7 @@ WORKDIR /workspace The daemon itself stays root; demotion is per command, with `exec --user 1000 --group 1000`. [Run coding agents on Bedrock](/learn/operations/run-coding-agents-on-bedrock/) shows why that uid is not decoration. -To make that user the default for every `exec` and `run --exec` without `--user`, set it in the image: +An exec without `--user` runs as root unless the image sets a default user: ```dockerfile ENV AGENTD_DEFAULT_USER=agent @@ -79,7 +79,7 @@ ENV AGENTD_DEFAULT_USER=agent The daemon resolves the value at each exec to its `/etc/passwd` row, so the row can come from an earlier exec. A name and its uid are the same: `AGENTD_DEFAULT_USER=1000` runs as `agent` with group 1000 and `HOME=/workspace`, and a uid with no row fails the exec rather than run it in root's group. An exec with `--user` runs as that user instead, `--user root` included, and hook handlers still run as root. -The default user also owns what `microvm cp` and `run . --exec` upload. The files and directories an upload creates are that user's, so the default user can edit the synced tree. Anything already in the guest keeps its owner. That includes `/workspace` itself, which the image's `chown 1000:1000 /workspace` hands to the agent. The daemon refuses an upload as `unknown_user` when the default has no row. +Uploads don't follow the variable. `microvm cp` and `run . --exec` write through the daemon as root, so what they create is root's, with no group or other write bit on a synced tree. The default user can read the synced files but can't change them or write into the directories the sync made, even under a `/workspace` the image handed to it. An exec that must write there runs with `--user 0`. Otherwise, a `chown` run with `--user 0` gives the tree to the default user first, as this section's Dockerfile does for `/workspace`. ## 5. No secret goes in the image diff --git a/site/authored/learn/tutorial/long-lived-vm.md b/site/authored/learn/tutorial/long-lived-vm.md index 6b4adc63..df2f8fa9 100644 --- a/site/authored/learn/tutorial/long-lived-vm.md +++ b/site/authored/learn/tutorial/long-lived-vm.md @@ -59,7 +59,7 @@ microvm cp --tar ./project.tar vm:/workspace --name dev microvm cp --tar vm:/workspace ./workspace-backup.tar --name dev ``` -`vm:/path` names the VM side; anything else is a local path. `--tar` moves a whole directory tree as an uncompressed tar archive, and extraction in the guest is confined so a hostile archive cannot write outside its target. `--mode` sets the permissions of an uploaded file, octal as a string (`644`, `0755`). The daemon writes as root, so what an upload creates is root's unless the image sets `AGENTD_DEFAULT_USER`, which then owns it. Choose a mode that permits the intended reader; +`vm:/path` names the VM side; anything else is a local path. `--tar` moves a whole directory tree as an uncompressed tar archive, and extraction in the guest is confined so a hostile archive cannot write outside its target. `--mode` sets the permissions of an uploaded file, octal as a string (`644`, `0755`). The daemon writes as root. Choose a mode that permits the intended reader; change ownership when a demoted workload needs to write the file or read an owner-only file. diff --git a/verify/guards/faults/default-user.toml b/verify/guards/faults/default-user.toml index 8931dae4..f9ad7a5a 100644 --- a/verify/guards/faults/default-user.toml +++ b/verify/guards/faults/default-user.toml @@ -100,72 +100,6 @@ expect = "test-failed" suite = "rust" transform = { file = "crates/agentd/src/exec_start.rs", replace = " (None, Some(name)) => match default_user_row(name, rows) {\n", with = " (None, Some(name)) if digits(name).is_some() => {\n resolve_user(&NameOrId::Name(name.to_string()), rows).map(Some)\n }\n (None, Some(name)) => match default_user_row(name, rows) {\n" } -# Uploads under the default user (the second review finding on #600): each entry an upload -# creates belongs to the default user's row, what existed keeps its owner, and a default with -# no row refuses the upload. The fs tests run unprivileged, so a chown to root is the EPERM -# that shows a chown happened. -[[fault]] -id = "upload-owner-dropped-at-the-tar-route" -guard = "fs::tests::the_routes_give_new_entries_the_default_users_row" -run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::the_routes_give_new_entries_the_default_users_row"] -expect = "test-failed" -suite = "rust" -transform = { file = "crates/agentd/src/fs.rs", replace = " extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n))\n", with = " extract_into(&root, spool, caps, &guard, owner.and(None)).map(|n| (root, n))\n" } - -[[fault]] -id = "upload-under-a-default-with-no-row-proceeds" -guard = "fs::tests::an_upload_under_a_default_with_no_row_is_refused_before_anything_lands" -run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::an_upload_under_a_default_with_no_row_is_refused_before_anything_lands"] -expect = "test-failed" -suite = "rust" -transform = { file = "crates/agentd/src/fs.rs", replace = " Err(why) => {\n let detail = format!(", with = " Err(_) if name.is_empty() || !name.is_empty() => Ok(None),\n Err(why) => {\n let detail = format!(" } - -[[family]] -guard = "fs::tests::an_upload_gives_each_entry_it_creates_the_owner" -run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::an_upload_gives_each_entry_it_creates_the_owner"] -expect = "test-failed" -suite = "rust" - -[[family.fault]] -id = "upload-new-directories-keep-roots-owner" -transform = { file = "crates/agentd/src/fs.rs", replace = " if created {\n self.own(current.as_fd(), None)\n", with = " if created && self.owner.is_none() {\n self.own(current.as_fd(), None)\n" } - -[[family.fault]] -id = "upload-new-files-keep-roots-owner" -transform = { file = "crates/agentd/src/fs.rs", replace = " self.own(fd.as_fd(), previous)\n", with = " self.own(fd.as_fd(), previous.or(Some(Owner { uid: u32::MAX, gid: u32::MAX })))\n" } - -[[family.fault]] -id = "upload-new-symlinks-keep-roots-owner" -transform = { file = "crates/agentd/src/fs.rs", replace = " if self.owner.is_some() {\n let link = Self::open_link(", with = " if self.owner.is_none() {\n let link = Self::open_link(" } - -[[family.fault]] -id = "upload-new-root-parents-keep-roots-owner" -transform = { file = "crates/agentd/src/fs.rs", replace = " if created {\n owner.apply(current.as_fd())?;\n", with = " if created && owner.uid == u32::MAX {\n owner.apply(current.as_fd())?;\n" } - -[[fault]] -id = "upload-replaced-entry-takes-the-default" -guard = "fs::tests::an_upload_leaves_what_already_existed_its_owner" -run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::an_upload_leaves_what_already_existed_its_owner"] -expect = "test-failed" -suite = "rust" -transform = { file = "crates/agentd/src/fs.rs", replace = " Some(owner) => previous.unwrap_or(owner).apply(fd),\n", with = " Some(owner) => previous.and(Some(owner)).unwrap_or(owner).apply(fd),\n" } - -[[fault]] -id = "upload-symlink-chown-takes-whatever-the-name-holds" -guard = "fs::tests::a_symlinks_chown_opens_the_link_itself" -run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::a_symlinks_chown_opens_the_link_itself"] -expect = "test-failed" -suite = "rust" -transform = { file = "crates/agentd/src/fs.rs", replace = " if kind == SFlag::S_IFLNK {\n", with = " if kind == SFlag::S_IFLNK || kind != SFlag::S_IFLNK {\n" } - -[[fault]] -id = "file-write-gives-a-new-file-no-owner" -guard = "fs::tests::a_file_write_gives_the_owner_only_a_file_it_creates" -run = ["cargo", "test", "-p", "agentd", "--lib", "--", "--exact", "fs::tests::a_file_write_gives_the_owner_only_a_file_it_creates"] -expect = "test-failed" -suite = "rust" -transform = { file = "crates/agentd/src/fs.rs", replace = " if let (true, Some(owner)) = (created, owner)\n", with = " if let (true, Some(owner)) = (created && owner.is_none(), owner)\n" } - # The model's AGENTD-22 property: an all-digit default with a row resolved as a request's digits # are, a bare uid in the daemon's group. [[fault]] @@ -214,7 +148,7 @@ expect = "test-failed" suite = "rust" transform = { file = "crates/model/src/exec_start.rs", replace = " (UserReq::Absent, Some(default), _) => (Account::Default, default),\n", with = " (UserReq::Absent, Some(_), _) => (Account::Daemon, UserReq::Absent),\n" } -# FAIL_TO_PASS: fails on 78e5f1d22d56 with the fix taken out (tools/fail-to-pass.py). +# FAIL_TO_PASS: fails on f410ec2f4c80 with the fix taken out (tools/fail-to-pass.py). [[fault]] id = "f2p-install-access-uploads-then-chowns-as-root" guard = "agents::tests::install_access_uploads_then_chowns_as_root" @@ -223,7 +157,7 @@ expect = "test-failed" suite = "rust" patch = "verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch" -# FAIL_TO_PASS: fails on 78e5f1d22d56 with the fix taken out (tools/fail-to-pass.py). +# FAIL_TO_PASS: fails on f410ec2f4c80 with the fix taken out (tools/fail-to-pass.py). [[fault]] id = "f2p-a-hostile-deletion-path-is-refused-not-executed" guard = "workspace::tests::a_hostile_deletion_path_is_refused_not_executed" diff --git a/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch b/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch index 5f294b9d..8ad8bb19 100644 --- a/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch +++ b/verify/guards/faults/f2p-a-hostile-deletion-path-is-refused-not-executed.patch @@ -100,10 +100,10 @@ index c6bd7f9..e9684f0 100644 }, )?; diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs -index 453b4f5..4e0d7b3 100644 +index 3ddcca0..4e0d7b3 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs -@@ -19,18 +19,6 @@ +@@ -19,17 +19,6 @@ //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). //! @@ -116,13 +116,12 @@ index 453b4f5..4e0d7b3 100644 -//! default doesn't keep the daemon's gid 0. A default with no row, digits included, is -//! refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's -//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default --//! decides what a caller gets by not saying, not what a caller may ask for. The same row --//! owns what an upload creates (`fs.rs`). +-//! decides what a caller gets by not saying, not what a caller may ask for. -//! //! # The environment is five layers, lowest first //! //! 1. The image environment, only when the request sets `inherit_image_env`: what the -@@ -64,8 +52,6 @@ use protocol::exec::{ +@@ -63,8 +52,6 @@ use protocol::exec::{ NameOrId, Shell, StartRequest, }; @@ -131,7 +130,7 @@ index 453b4f5..4e0d7b3 100644 /// A child's environment, or one layer of it. pub type Env = HashMap; -@@ -173,8 +159,6 @@ pub struct Guest<'a> { +@@ -172,8 +159,6 @@ pub struct Guest<'a> { pub passwd: &'a str, /// The group file's text. Empty when the file is absent. pub group: &'a str, @@ -140,7 +139,7 @@ index 453b4f5..4e0d7b3 100644 /// Whether a path is an executable regular file. The real check is /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. pub is_executable: &'a dyn Fn(&Path) -> bool, -@@ -217,10 +201,8 @@ impl Plan { +@@ -216,10 +201,8 @@ impl Plan { struct User<'a> { uid: u32, row: Option<&'a PasswdRow>, @@ -153,7 +152,7 @@ index 453b4f5..4e0d7b3 100644 } /// All ASCII digits that fit a `u32`: a string read as an id when it names no row. -@@ -239,21 +221,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, +@@ -238,21 +221,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, NameOrId::Id(uid) => Ok(User { uid: *uid, row: by_uid(*uid), @@ -178,7 +177,7 @@ index 453b4f5..4e0d7b3 100644 }), None => Err(Rejection::new( ERROR_UNKNOWN_USER, -@@ -264,56 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, +@@ -263,56 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, } } @@ -186,12 +185,12 @@ index 453b4f5..4e0d7b3 100644 -/// row of the uid its digits spell. -/// -/// Stricter than a request's `user`, where digits with no row run as a bare uid. The default --/// runs every start that names no user and owns every entry an upload creates, and a uid --/// with no row has no primary gid to give either, so the child would keep the daemon's gid --/// 0. A default with no row is refused instead (AGENTD-24). The error is the clause a --/// refusal's detail ends with, naming the value and the variable that set it, because the --/// caller sent no name to look for and the fix is in the image. --pub fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { +-/// runs every start that names no user, and a uid with no row has no primary gid to give it, +-/// so the child would keep the daemon's gid 0. A default with no row is refused instead +-/// (AGENTD-24). The error is the clause a refusal's detail ends with, naming the value and +-/// the variable that set it, because the caller sent no name to look for and the fix is in +-/// the image. +-fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { - match resolve_user(&NameOrId::Name(name.to_string()), rows) { - Ok(User { row: Some(row), .. }) => Ok(row), - Ok(User { row: None, .. }) => Err(format!( @@ -235,7 +234,7 @@ index 453b4f5..4e0d7b3 100644 /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { match spec { -@@ -389,12 +321,16 @@ pub fn resolve_shell( +@@ -388,12 +321,16 @@ pub fn resolve_shell( /// environment's `PATH`), so the first field that cannot be resolved names the error. The /// empty-argv check comes last, as it did in `build_command` before this module existed. pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { @@ -254,7 +253,7 @@ index 453b4f5..4e0d7b3 100644 let groups = match &request.group { Some(NameOrId::Name(_)) => parse_group(guest.group), -@@ -402,12 +338,11 @@ pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result) -> Result Some(resolve_group(spec, &groups)?), @@ -270,544 +269,6 @@ index 453b4f5..4e0d7b3 100644 .and_then(|user| user.row) .map(|row| row.gid), }; -diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 6844bcb..f6221ee 100644 ---- a/crates/agentd/src/fs.rs -+++ b/crates/agentd/src/fs.rs -@@ -65,25 +65,7 @@ - //! resolution that would leave the root stops with `EXDEV`. Both become a 400 that - //! names the member. The daemon no longer has to reason about whether a redirect is - //! safe, because a redirected write cannot happen. --//! --//! # Who owns what an upload creates --//! --//! The daemon writes as root, so without `AGENTD_DEFAULT_USER` every entry an upload creates --//! is root's, as it always was. With it set, each entry `PUT /v1/fs/tar` or --//! `PUT /v1/fs/file` creates, parent directories included, is owned by the default user's --//! uid and primary gid, resolved by the rule a start that names no user uses --//! (`exec_start::default_user_row`), so the user a nameless exec runs as can write the tree --//! an upload made for it. An entry that was already there keeps its owner: an existing --//! directory, a file `PUT /v1/fs/file` truncates, and a name the archive replaces, whose new --//! entry takes the old one's owner. A hard link is a name for an inode that already exists, --//! so it changes no owner. A default with no passwd row refuses the upload with --//! `400 unknown_user` before anything is written. --//! --//! Each chown goes through a descriptor of the entry itself, never a path: the open file, --//! or a directory or symlink opened in its confined parent without following a symlink. A --//! name swapped between the create and the chown can't move the chown onto another file. - --use std::borrow::Cow; - use std::collections::HashMap; - use std::ffi::{OsStr, OsString}; - use std::io::{self, Read, Seek, SeekFrom, Write}; -@@ -93,22 +75,19 @@ use std::path::{Component, Path, PathBuf}; - - use nix::errno::Errno; - use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, OpenHow, ResolveFlag, openat2}; --use nix::sys::stat::{FchmodatFlags, Mode, SFlag, fchmodat, fstat, fstatat, mkdirat}; --use nix::unistd::{Gid, Uid, UnlinkatFlags, fchownat, linkat, symlinkat, unlinkat}; -+use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; -+use nix::unistd::{UnlinkatFlags, linkat, symlinkat, unlinkat}; - --use axum::Json; - use axum::body::Body; - use axum::extract::{Request, State}; - use axum::http::{StatusCode, header}; - use axum::response::{IntoResponse, Response}; - use bytes::Bytes; - use futures_util::TryStreamExt; --use protocol::exec::{ERROR_UNKNOWN_USER, ErrorBody}; - use tar::EntryType; - use tokio_util::io::{ReaderStream, StreamReader}; - - use crate::disk::{self, CopyError}; --use crate::exec_start::{default_user_row, parse_passwd, read_database}; - use crate::state::AppState; - - /// The two typed shapes in this module, re-exported from their original paths. The -@@ -144,116 +123,6 @@ fn insufficient_storage(path: &Path, reading: disk::Reading) -> Response { - .into_response() - } - --/// The owner of an entry an upload creates: the image's default user's uid and primary gid. --/// `None` in place of one, throughout this module, leaves an entry the daemon's, which is --/// root. --#[derive(Clone, Copy, Debug, Eq, PartialEq)] --struct Owner { -- uid: u32, -- gid: u32, --} -- --impl Owner { -- /// Gives the entry `fd` refers to this owner. `fd` may be `O_PATH`: with an empty path -- /// and `AT_EMPTY_PATH`, `fchownat` changes the descriptor's own inode, a symlink's -- /// included. -- fn apply(self, fd: BorrowedFd<'_>) -> Result<(), Errno> { -- fchownat( -- fd, -- "", -- Some(Uid::from_raw(self.uid)), -- Some(Gid::from_raw(self.gid)), -- AtFlags::AT_EMPTY_PATH, -- ) -- } --} -- --/// The owner of what this upload creates, or the detail of the `400 unknown_user` that --/// refuses it. --/// --/// Resolved at each upload from the guest's `/etc/passwd`, the way a start resolves the --/// default, so a user an earlier exec created owns the next upload. A default with no row --/// is refused here, before the body is read, because extracting it as root would leave --/// the default user a tree it can't write. The `Err` is the detail rather than a --/// `Response` so the variant stays small (clippy's `result_large_err`); --/// [`unknown_default_user`] builds the answer. --fn upload_owner(state: &AppState) -> Result, String> { -- let config = state.config(); -- let Some(name) = config.default_user.as_deref() else { -- return Ok(None); -- }; -- let rows = parse_passwd(&read_database(&config.passwd_path)); -- match default_user_row(name, &rows) { -- Ok(row) => Ok(Some(Owner { -- uid: row.uid, -- gid: row.gid, -- })), -- Err(why) => { -- let detail = format!("an upload's new entries belong to the default user, and {why}"); -- Err(detail) -- } -- } --} -- --/// The `400 unknown_user` that refuses an upload under a default with no passwd row. The --/// body is the exec routes' JSON `ErrorBody`, since the slug is theirs, where this module's --/// other refusals are plain text. --fn unknown_default_user(detail: String) -> Response { -- tracing::warn!(detail, "upload refused"); -- ( -- StatusCode::BAD_REQUEST, -- Json(ErrorBody { -- error: Cow::Borrowed(ERROR_UNKNOWN_USER), -- detail, -- }), -- ) -- .into_response() --} -- --/// Opens the directory `path`, creating each missing component as `create_dir_all` would, --/// and gives each component it creates to `owner`. --/// --/// Walked a component at a time from `/`, or from the working directory for a relative --/// path, each step relative to the descriptor of the last. A component that already exists --/// is followed wherever it leads, symlinks included, as `create_dir_all` follows them, --/// because the path is the caller's. A component this walk creates is opened with --/// [`Confined::RESOLVE`] and chowned through that descriptor, so a symlink put in its --/// place after the `mkdirat` stops the walk instead of taking the chown. --fn open_dirs_owned(path: &Path, owner: Owner) -> io::Result { -- let open = |dir: BorrowedFd<'_>, name: &OsStr, resolve: ResolveFlag| { -- let how = OpenHow::new().flags(Confined::DIRECTORY).resolve(resolve); -- openat2(dir, name, how) -- }; -- let start = if path.is_absolute() { "/" } else { "." }; -- let mut current = open( -- AT_FDCWD, -- OsStr::new(start), -- ResolveFlag::RESOLVE_NO_MAGICLINKS, -- )?; -- for component in path.components() { -- let name = match component { -- Component::Normal(name) => name, -- Component::ParentDir => OsStr::new(".."), -- Component::RootDir | Component::CurDir | Component::Prefix(_) => continue, -- }; -- let created = match mkdirat(¤t, name, Mode::from_bits_truncate(0o777)) { -- Ok(()) => true, -- Err(Errno::EEXIST) => false, -- Err(errno) => return Err(errno.into()), -- }; -- let resolve = if created { -- Confined::RESOLVE -- } else { -- ResolveFlag::RESOLVE_NO_MAGICLINKS -- }; -- current = open(current.as_fd(), name, resolve)?; -- if created { -- owner.apply(current.as_fd())?; -- } -- } -- Ok(current) --} -- - /// Why an archive or member was refused. - /// - /// The refused member's name travels with the refusal so the 400 can name it. A -@@ -427,9 +296,6 @@ fn resolve_member(root: &Path, path: &Path) -> Landing { - /// read from or written to even if the descriptor leaks somewhere it should not. - struct Confined { - root: OwnedFd, -- /// Who owns each entry the extraction creates (see the module comment). `None` leaves -- /// them the daemon's. -- owner: Option, - } - - impl Confined { -@@ -463,19 +329,7 @@ impl Confined { - .union(ResolveFlag::RESOLVE_NO_SYMLINKS) - .union(ResolveFlag::RESOLVE_NO_MAGICLINKS); - -- /// The open flags of every directory on a walk: a path for `*at` calls only, which -- /// fails unless the name is a directory. -- const DIRECTORY: OFlag = OFlag::O_CLOEXEC -- .union(OFlag::O_PATH) -- .union(OFlag::O_DIRECTORY); -- -- /// The open flags of a symlink opened as itself, for its chown ([`Confined::open_link`]). -- const LINK: OFlag = OFlag::O_CLOEXEC -- .union(OFlag::O_PATH) -- .union(OFlag::O_NOFOLLOW); -- -- /// Opens `root`, creating it if it is absent, and holds it. With an `owner`, each -- /// directory created on the way to `root` is that owner's ([`open_dirs_owned`]). -+ /// Opens `root`, creating it if it is absent, and holds it. - /// - /// The root itself is opened by absolute path with only - /// `RESOLVE_NO_MAGICLINKS`, not with [`Self::RESOLVE`]. `RESOLVE_BENEATH` -@@ -493,18 +347,13 @@ impl Confined { - /// and `File::create` this replaced: falling back would mean the confinement a - /// caller cannot see silently became the weaker one, and issue #15 is what the - /// weaker one allows. -- fn open(root: &Path, owner: Option) -> io::Result { -- let root = match owner { -- Some(owner) => open_dirs_owned(root, owner)?, -- None => { -- std::fs::create_dir_all(root)?; -- let how = OpenHow::new() -- .flags(Self::DIRECTORY) -- .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); -- openat2(AT_FDCWD, root, how).map_err(io::Error::from)? -- } -- }; -- Ok(Self { root, owner }) -+ fn open(root: &Path) -> io::Result { -+ std::fs::create_dir_all(root)?; -+ let how = OpenHow::new() -+ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) -+ .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); -+ let root = openat2(AT_FDCWD, root, how).map_err(io::Error::from)?; -+ Ok(Self { root }) - } - - /// Opens the single component `name` under `dir` as a directory. -@@ -514,7 +363,9 @@ impl Confined { - /// the error name which component failed, and a 400 that names the component - /// is the difference between an operator fixing their tree and re-reading it. - fn open_dir(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Result { -- let how = OpenHow::new().flags(Self::DIRECTORY).resolve(Self::RESOLVE); -+ let how = OpenHow::new() -+ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) -+ .resolve(Self::RESOLVE); - openat2(dir, name, how) - } - -@@ -524,8 +375,7 @@ impl Confined { - /// not an error: an archive may name the same directory twice, and a partial - /// extraction that is retried has to converge. The open that follows decides - /// whether the existing name is usable, and a symlink there fails with -- /// `ELOOP` rather than being traversed. A directory this walk creates is given the -- /// extraction's owner through that descriptor; one that existed keeps its own. -+ /// `ELOOP` rather than being traversed. - fn make_dirs(&self, parts: &[OsString]) -> Result { - let mut current = self.root.try_clone().map_err(|err| WalkError { - component: None, -@@ -536,33 +386,17 @@ impl Confined { - // replayed by `apply_deferred_modes` after every member has landed. - // Applying it here is the defect that pass exists to avoid: a 0o500 - // directory blocks the writes of its own children. -- let created = match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) -- { -- Ok(()) => true, -- Err(Errno::EEXIST) => false, -+ match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) { -+ Ok(()) | Err(Errno::EEXIST) => {} - Err(errno) => return Err(WalkError::at(parts, index, errno)), -- }; -+ } - current = self - .open_dir(current.as_fd(), part) - .map_err(|errno| WalkError::at(parts, index, errno))?; -- if created { -- self.own(current.as_fd(), None) -- .map_err(|errno| WalkError::at(parts, index, errno))?; -- } - } - Ok(current) - } - -- /// Gives an entry this extraction created its owner: `previous`, the owner of the entry -- /// it replaced, when there was one, and the extraction's otherwise. Nothing without an -- /// extraction owner, so an unset default leaves every entry the daemon's. -- fn own(&self, fd: BorrowedFd<'_>, previous: Option) -> Result<(), Errno> { -- match self.owner { -- Some(owner) => previous.unwrap_or(owner).apply(fd), -- None => Ok(()), -- } -- } -- - /// Splits `parts` into the parent directory descriptor and the final name. - /// - /// Every leaf creation needs exactly this: a `dirfd` the kernel has already -@@ -594,13 +428,9 @@ impl Confined { - fn create_file(&self, parts: &[OsString]) -> Result { - let (parent, name) = self.parent_of(parts)?; - let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); -+ self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) - .map_err(|errno| WalkError::at(parts, last, errno))?; -- // Through the descriptor `O_EXCL` created, which is this file whatever the name -- // holds by now. -- self.own(fd.as_fd(), previous) -- .map_err(|errno| WalkError::at(parts, last, errno))?; - Ok(std::fs::File::from(fd)) - } - -@@ -628,34 +458,9 @@ impl Confined { - fn create_symlink(&self, parts: &[OsString], target: &Path) -> Result<(), WalkError> { - let (parent, name) = self.parent_of(parts)?; - let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); -+ self.replace(parent.as_fd(), &name); - symlinkat(target, parent.as_fd(), name.as_os_str()) -- .map_err(|errno| WalkError::at(parts, last, errno))?; -- if self.owner.is_some() { -- let link = Self::open_link(parent.as_fd(), &name) -- .map_err(|errno| WalkError::at(parts, last, errno))?; -- self.own(link.as_fd(), previous) -- .map_err(|errno| WalkError::at(parts, last, errno))?; -- } -- Ok(()) -- } -- -- /// Opens the symlink `name` under `dir` itself, `O_PATH` and without following it, for -- /// its chown. -- /// -- /// The name is opened rather than chowned in place, and the result has to be a -- /// symlink, because a process that can write `dir` can swap the link for a hard link -- /// to another file between the `symlinkat` and the chown, and a chown by name would then -- /// hand that file to the default user. `EEXIST` says the name no longer holds the link. -- fn open_link(dir: BorrowedFd<'_>, name: &OsStr) -> Result { -- let how = OpenHow::new().flags(Self::LINK).resolve(Self::RESOLVE); -- let link = openat2(dir, name, how)?; -- let kind = SFlag::from_bits_truncate(fstat(&link)?.st_mode) & SFlag::S_IFMT; -- if kind == SFlag::S_IFLNK { -- Ok(link) -- } else { -- Err(Errno::EEXIST) -- } -+ .map_err(|errno| WalkError::at(parts, last, errno)) - } - - /// Creates a hard link at `parts` pointing at `source`, an archive-root-relative -@@ -674,7 +479,6 @@ impl Confined { - ) -> Result<(), HardLinkError> { - let (parent, name) = self.parent_of(parts).map_err(HardLinkError::Dest)?; - let last = parts.len() - 1; -- // No chown: the link names an inode that already exists, whose owner it keeps. - self.replace(parent.as_fd(), &name); - - // An empty source is the archive naming the root as a hard-link target. -@@ -720,25 +524,14 @@ impl Confined { - Ok(current) - } - -- /// Unlinks `name` under `dir`, ignoring every failure, and returns the owner of what -- /// was there when the extraction gives its entries an owner, so the new entry can keep -- /// it. -+ /// Unlinks `name` under `dir`, ignoring every failure. - /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is - /// `ENOENT`, which means there was nothing to replace, and any other means the -- /// create below fails and reports the real reason. The owner is read without following -- /// a symlink, so a replaced link reports its own. -- fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Option { -- let previous = self -- .owner -- .and_then(|_| fstatat(dir, name, AtFlags::AT_SYMLINK_NOFOLLOW).ok()) -- .map(|stat| Owner { -- uid: stat.st_uid, -- gid: stat.st_gid, -- }); -+ /// create below fails and reports the real reason. -+ fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { - let _ = unlinkat(dir, name, UnlinkatFlags::NoRemoveDir); -- previous - } - - /// Applies `mode` to `parts`, without following a symlink at the last component. -@@ -853,11 +646,10 @@ pub fn fuzz_extract( - probe: disk::available_bytes, - reserve: 0, - }; -- extract_into(root, archive, caps, &guard, None).map_err(|refusal| format!("{refusal:?}")) -+ extract_into(root, archive, caps, &guard).map_err(|refusal| format!("{refusal:?}")) - } - --/// Extracts `archive` into `root`, enforcing the data-filter contract, with each entry it --/// creates given to `owner` when there is one (see the module comment). -+/// Extracts `archive` into `root`, enforcing the data-filter contract. - /// - /// Synchronous on purpose: `tar`'s reader is blocking, so callers hand this to - /// `spawn_blocking` rather than pretending it is async. -@@ -866,18 +658,17 @@ fn extract_into( - archive: impl Read, - caps: Caps, - guard: &disk::Guard, -- owner: Option, - ) -> Result { - // The kernel's half of the confinement, held for the whole extraction. Every - // member below is created through this rather than by absolute path, so a - // component that turns out to be a symlink stops the write instead of - // redirecting it. See the module comment. -- let confined = Confined::open(root, owner)?; -+ let confined = Confined::open(root)?; - - let mut ar = tar::Archive::new(archive); -- // The archive's ownership is dropped for the same reason CPython's `data` filter -- // drops it: its uid/gid are the packing host's, meaningless here, and honoring them -- // would need privilege we should not spend. Who owns an entry is `owner`'s to say. -+ // Ownership is dropped for the same reason CPython's `data` filter drops it: -+ // the archive's uid/gid are the packing host's, meaningless here, and honoring -+ // them would need privilege we should not spend. - ar.set_preserve_permissions(false); - ar.set_preserve_ownerships(false); - ar.set_unpack_xattrs(false); -@@ -1404,8 +1195,7 @@ fn sliced_stream( - }) - } - --/// Writes one file. Not confined to a root; see the module comment, which also says who --/// owns the file and the parents this creates. -+/// Writes one file. Not confined to a root; see the module comment. - pub async fn write_file(State(state): State, request: Request) -> Response { - let query = match fs_query(&request) { - Ok(query) => query, -@@ -1432,13 +1222,6 @@ pub async fn write_file(State(state): State, request: Request) -> Resp - }, - }; - -- // Like the mode, before anything lands: a default user with no passwd row refuses the -- // write rather than leaving it a root-owned file. -- let owner = match upload_owner(&state) { -- Ok(owner) => owner, -- Err(detail) => return unknown_default_user(detail), -- }; -- - // Checked before the parent directory is created and before the file is - // opened, so a refused write leaves nothing behind — not even an empty file - // where the caller's data was supposed to go. An empty file at the target path -@@ -1447,61 +1230,24 @@ pub async fn write_file(State(state): State, request: Request) -> Resp - return insufficient_storage(&path, reading); - } - -- if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { -- let created = match owner { -- None => tokio::fs::create_dir_all(parent).await, -- Some(owner) => { -- let parent = parent.to_path_buf(); -- tokio::task::spawn_blocking(move || open_dirs_owned(&parent, owner).map(drop)) -- .await -- .unwrap_or_else(|err| Err(io::Error::other(err))) -- } -- }; -- if let Err(err) = created { -- tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); -- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); -- } -+ if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) -+ && let Err(err) = tokio::fs::create_dir_all(parent).await -+ { -+ tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); -+ return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); - } - -- let options = |create_new: bool| { -- let mut options = tokio::fs::OpenOptions::new(); -- options.write(true).truncate(true); -- if create_new { -- options.create_new(true); -- } else { -- options.create(true); -- } -- if let Some(mode) = mode { -- // Set at open, so the file never exists with a wider mode than asked for -- // even briefly. A separate chmod after writing leaves that window open, -- // which matters when the file is a credential. -- options.mode(mode); -- } -- options -- }; -- // With an owner to give, `O_EXCL` first, so the daemon knows whether this write -- // created the file: one it only truncates keeps its owner. -- let opened = match owner { -- None => options(false).open(&path).await.map(|file| (file, false)), -- // The usual failure is that the file exists, which the plain open truncates; any -- // other, the plain open meets again and reports. -- Some(_) => match options(true).open(&path).await { -- Ok(file) => Ok((file, true)), -- Err(_) => options(false).open(&path).await.map(|file| (file, false)), -- }, -- }; -- let mut file = match opened { -- Ok((file, created)) => { -- if let (true, Some(owner)) = (created, owner) -- && let Err(errno) = owner.apply(file.as_fd()) -- { -- // The file is new and empty, and nobody was told it exists. -- let _ = tokio::fs::remove_file(&path).await; -- tracing::warn!(path = %path.display(), %errno, "cannot give the file its owner"); -- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot set owner").into_response(); -- } -- file -- } -+ let mut options = tokio::fs::OpenOptions::new(); -+ options.write(true).create(true).truncate(true); -+ if let Some(mode) = mode { -+ // Set at open, so the file never exists with a wider mode than asked for -+ // even briefly. A separate chmod after writing leaves that window open, -+ // which matters when the file is a credential. -+ options.mode(mode); -+ } -+ -+ let mut file = match options.open(&path).await { -+ Ok(file) => file, - Err(err) => { - tracing::warn!(path = %path.display(), %err, "cannot open file for write"); - return (StatusCode::INTERNAL_SERVER_ERROR, "cannot write file").into_response(); -@@ -1718,13 +1464,6 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo - .into_response(); - } - -- // Before the body is spooled, so a default user with no passwd row refuses the upload -- // without receiving it and before any member lands. -- let owner = match upload_owner(&state) { -- Ok(owner) => owner, -- Err(detail) => return unknown_default_user(detail), -- }; -- - let caps = Caps { - max_members: state.config().max_tar_members, - max_bytes: state.config().max_tar_bytes, -@@ -1755,7 +1494,7 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo - }; - - let extracted = tokio::task::spawn_blocking(move || { -- extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n)) -+ extract_into(&root, spool, caps, &guard).map(|n| (root, n)) - }) - .await; - diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs index 1549ac9..b30f398 100644 --- a/crates/agentd/src/hook_handlers.rs @@ -823,104 +284,22 @@ index 1549ac9..b30f398 100644 //! over loopback from inside the guest, so any process in the VM can make a handler //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many //! times that can happen. -diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..eccaf3c 100644 ---- a/crates/agentd/src/schema.rs -+++ b/crates/agentd/src/schema.rs -@@ -602,12 +602,7 @@ pub const FS_READ_FILE: &[Status] = &[ - ]; - - pub const FS_WRITE_FILE: &[Status] = &[ -- Status::new( -- StatusCode::NO_CONTENT, -- "", -- "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or \ -- parent directory the write creates belongs to the default user.", -- ), -+ Status::new(StatusCode::NO_CONTENT, "", "written, with the mode applied"), - Status::new( - StatusCode::BAD_REQUEST, - "", -@@ -615,7 +610,6 @@ pub const FS_WRITE_FILE: &[Status] = &[ - mode. Checked before a byte lands, so a rejected mode leaves nothing \ - behind.", - ), -- UPLOAD_UNKNOWN_USER, - UNAUTHORIZED, - NOT_BOOTSTRAPPED, - BODY_TOO_LARGE, -@@ -623,8 +617,7 @@ pub const FS_WRITE_FILE: &[Status] = &[ - Status::new( - StatusCode::INTERNAL_SERVER_ERROR, - "", -- "the parent could not be created, the write or chmod failed, or a file the \ -- write created could not be given the default user, in which case it is removed", -+ "the parent could not be created, or the write or chmod failed", - ), - ]; - -@@ -661,24 +654,8 @@ pub const FS_READ_TAR: &[Status] = &[ - ), - ]; - --/// The refusal both upload routes share when the image's default user has no passwd row. --/// Unlike the routes' other refusals, its body is the JSON `ErrorBody`, since the slug is --/// the one a start answers. --const UPLOAD_UNKNOWN_USER: Status = Status::new( -- StatusCode::BAD_REQUEST, -- "unknown_user", -- "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so \ -- nothing the upload creates could belong to it. Checked before the body is read; \ -- nothing was written, and detail names the value and the variable.", --); -- - pub const FS_WRITE_TAR: &[Status] = &[ -- Status::new( -- StatusCode::NO_CONTENT, -- "", -- "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction \ -- creates belongs to the default user.", -- ), -+ Status::new(StatusCode::NO_CONTENT, "", "extracted under ?path="), - Status::new( - StatusCode::BAD_REQUEST, - "", -@@ -687,7 +664,6 @@ pub const FS_WRITE_TAR: &[Status] = &[ - path, an absolute or out-of-tree link target, or a device or fifo member. \ - The refused member's name is in the body.", - ), -- UPLOAD_UNKNOWN_USER, - UNAUTHORIZED, - NOT_BOOTSTRAPPED, - Status::new( diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs -index f7ca208..6ca3bcc 100644 +index 333dfae..6ca3bcc 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs -@@ -16,8 +16,7 @@ - //! 2. **The daemon's execs start from an empty environment**, so the agent's subshells - //! find no `PATH` and every command exits 127. The environment file sets it. - //! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent --//! cannot read, unless the image sets `AGENTD_DEFAULT_USER`. One root `chown` follows the --//! uploads either way. -+//! cannot read. One root `chown` follows the uploads. - //! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses - //! wire API, with an inference-profile model id and hosted web search disabled. Its - //! config file names that host for the launch region, and its credential comes from -@@ -492,10 +491,8 @@ pub fn provisioning_files( - +@@ -492,9 +492,7 @@ pub fn provisioning_files( /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// --/// The daemon writes an upload as root unless the image sets `AGENTD_DEFAULT_USER`, so the --/// `chown` is not optional and it runs as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming + /// The daemon writes every upload as root, so the `chown` is not optional and it runs +-/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming -/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user -/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call -+/// The daemon writes every upload as root, so the `chown` is not optional and it runs +/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is /// refreshed on a long-lived VM (AGENT-8). pub async fn install_access( -@@ -513,7 +510,6 @@ pub async fn install_access( +@@ -512,7 +510,6 @@ pub async fn install_access( vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], ) .with_shell(true) @@ -947,15 +326,15 @@ index e83657c..16a4ab8 100644 /// /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs -index f6c4505..f3d4bd5 100644 +index 11db62c..f3d4bd5 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,11 +367,8 @@ impl Session { .map(String::from) .chain(doomed) .collect(); -- // As root, by uid: a deleted path can sit in a directory root owns, `/workspace` -- // itself among them, and an image's default user would be refused deleting it. +- // As root, by uid: the daemon wrote the tree as root, and an image's default user +- // would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) - .with_user(Some(crate::session::ROOT_UID.into())) diff --git a/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch b/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch +++ b/verify/guards/faults/f2p-a-later-hard-link-preserves-its-targets-permissions.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch b/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch +++ b/verify/guards/faults/f2p-connections-past-the-capacity-are-closed.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch b/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch +++ b/verify/guards/faults/f2p-idle-keep-alive-connections-are-closed.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch b/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch +++ b/verify/guards/faults/f2p-incomplete-headers-have-a-deadline.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch b/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch index 5f294b9d..8ad8bb19 100644 --- a/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch +++ b/verify/guards/faults/f2p-install-access-uploads-then-chowns-as-root.patch @@ -100,10 +100,10 @@ index c6bd7f9..e9684f0 100644 }, )?; diff --git a/crates/agentd/src/exec_start.rs b/crates/agentd/src/exec_start.rs -index 453b4f5..4e0d7b3 100644 +index 3ddcca0..4e0d7b3 100644 --- a/crates/agentd/src/exec_start.rs +++ b/crates/agentd/src/exec_start.rs -@@ -19,18 +19,6 @@ +@@ -19,17 +19,6 @@ //! fuzz harness can drive. Supplementary groups are not set: `Command::groups` is still //! unstable, and std clears them anyway when a root daemon demotes (`setgroups(0)`). //! @@ -116,13 +116,12 @@ index 453b4f5..4e0d7b3 100644 -//! default doesn't keep the daemon's gid 0. A default with no row, digits included, is -//! refused as `unknown_user` with a detail that names the variable (AGENTD-24). A request's -//! own `user` always wins over the default, `root` and `0` included (AGENTD-23): the default --//! decides what a caller gets by not saying, not what a caller may ask for. The same row --//! owns what an upload creates (`fs.rs`). +-//! decides what a caller gets by not saying, not what a caller may ask for. -//! //! # The environment is five layers, lowest first //! //! 1. The image environment, only when the request sets `inherit_image_env`: what the -@@ -64,8 +52,6 @@ use protocol::exec::{ +@@ -63,8 +52,6 @@ use protocol::exec::{ NameOrId, Shell, StartRequest, }; @@ -131,7 +130,7 @@ index 453b4f5..4e0d7b3 100644 /// A child's environment, or one layer of it. pub type Env = HashMap; -@@ -173,8 +159,6 @@ pub struct Guest<'a> { +@@ -172,8 +159,6 @@ pub struct Guest<'a> { pub passwd: &'a str, /// The group file's text. Empty when the file is absent. pub group: &'a str, @@ -140,7 +139,7 @@ index 453b4f5..4e0d7b3 100644 /// Whether a path is an executable regular file. The real check is /// [`is_executable_file`]; tests and the fuzz harness hand in a fake filesystem. pub is_executable: &'a dyn Fn(&Path) -> bool, -@@ -217,10 +201,8 @@ impl Plan { +@@ -216,10 +201,8 @@ impl Plan { struct User<'a> { uid: u32, row: Option<&'a PasswdRow>, @@ -153,7 +152,7 @@ index 453b4f5..4e0d7b3 100644 } /// All ASCII digits that fit a `u32`: a string read as an id when it names no row. -@@ -239,21 +221,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, +@@ -238,21 +221,21 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, NameOrId::Id(uid) => Ok(User { uid: *uid, row: by_uid(*uid), @@ -178,7 +177,7 @@ index 453b4f5..4e0d7b3 100644 }), None => Err(Rejection::new( ERROR_UNKNOWN_USER, -@@ -264,56 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, +@@ -263,56 +246,6 @@ fn resolve_user<'a>(spec: &NameOrId, rows: &'a [PasswdRow]) -> Result, } } @@ -186,12 +185,12 @@ index 453b4f5..4e0d7b3 100644 -/// row of the uid its digits spell. -/// -/// Stricter than a request's `user`, where digits with no row run as a bare uid. The default --/// runs every start that names no user and owns every entry an upload creates, and a uid --/// with no row has no primary gid to give either, so the child would keep the daemon's gid --/// 0. A default with no row is refused instead (AGENTD-24). The error is the clause a --/// refusal's detail ends with, naming the value and the variable that set it, because the --/// caller sent no name to look for and the fix is in the image. --pub fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { +-/// runs every start that names no user, and a uid with no row has no primary gid to give it, +-/// so the child would keep the daemon's gid 0. A default with no row is refused instead +-/// (AGENTD-24). The error is the clause a refusal's detail ends with, naming the value and +-/// the variable that set it, because the caller sent no name to look for and the fix is in +-/// the image. +-fn default_user_row<'a>(name: &str, rows: &'a [PasswdRow]) -> Result<&'a PasswdRow, String> { - match resolve_user(&NameOrId::Name(name.to_string()), rows) { - Ok(User { row: Some(row), .. }) => Ok(row), - Ok(User { row: None, .. }) => Err(format!( @@ -235,7 +234,7 @@ index 453b4f5..4e0d7b3 100644 /// A group by gid or name (AGENTD-7); an unmatched name is `unknown_group` (AGENTD-8). fn resolve_group(spec: &NameOrId, rows: &[GroupRow]) -> Result { match spec { -@@ -389,12 +321,16 @@ pub fn resolve_shell( +@@ -388,12 +321,16 @@ pub fn resolve_shell( /// environment's `PATH`), so the first field that cannot be resolved names the error. The /// empty-argv check comes last, as it did in `build_command` before this module existed. pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result { @@ -254,7 +253,7 @@ index 453b4f5..4e0d7b3 100644 let groups = match &request.group { Some(NameOrId::Name(_)) => parse_group(guest.group), -@@ -402,12 +338,11 @@ pub fn plan(request: &StartRequest, guest: &Guest<'_>) -> Result) -> Result Some(resolve_group(spec, &groups)?), @@ -270,544 +269,6 @@ index 453b4f5..4e0d7b3 100644 .and_then(|user| user.row) .map(|row| row.gid), }; -diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 6844bcb..f6221ee 100644 ---- a/crates/agentd/src/fs.rs -+++ b/crates/agentd/src/fs.rs -@@ -65,25 +65,7 @@ - //! resolution that would leave the root stops with `EXDEV`. Both become a 400 that - //! names the member. The daemon no longer has to reason about whether a redirect is - //! safe, because a redirected write cannot happen. --//! --//! # Who owns what an upload creates --//! --//! The daemon writes as root, so without `AGENTD_DEFAULT_USER` every entry an upload creates --//! is root's, as it always was. With it set, each entry `PUT /v1/fs/tar` or --//! `PUT /v1/fs/file` creates, parent directories included, is owned by the default user's --//! uid and primary gid, resolved by the rule a start that names no user uses --//! (`exec_start::default_user_row`), so the user a nameless exec runs as can write the tree --//! an upload made for it. An entry that was already there keeps its owner: an existing --//! directory, a file `PUT /v1/fs/file` truncates, and a name the archive replaces, whose new --//! entry takes the old one's owner. A hard link is a name for an inode that already exists, --//! so it changes no owner. A default with no passwd row refuses the upload with --//! `400 unknown_user` before anything is written. --//! --//! Each chown goes through a descriptor of the entry itself, never a path: the open file, --//! or a directory or symlink opened in its confined parent without following a symlink. A --//! name swapped between the create and the chown can't move the chown onto another file. - --use std::borrow::Cow; - use std::collections::HashMap; - use std::ffi::{OsStr, OsString}; - use std::io::{self, Read, Seek, SeekFrom, Write}; -@@ -93,22 +75,19 @@ use std::path::{Component, Path, PathBuf}; - - use nix::errno::Errno; - use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, OpenHow, ResolveFlag, openat2}; --use nix::sys::stat::{FchmodatFlags, Mode, SFlag, fchmodat, fstat, fstatat, mkdirat}; --use nix::unistd::{Gid, Uid, UnlinkatFlags, fchownat, linkat, symlinkat, unlinkat}; -+use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; -+use nix::unistd::{UnlinkatFlags, linkat, symlinkat, unlinkat}; - --use axum::Json; - use axum::body::Body; - use axum::extract::{Request, State}; - use axum::http::{StatusCode, header}; - use axum::response::{IntoResponse, Response}; - use bytes::Bytes; - use futures_util::TryStreamExt; --use protocol::exec::{ERROR_UNKNOWN_USER, ErrorBody}; - use tar::EntryType; - use tokio_util::io::{ReaderStream, StreamReader}; - - use crate::disk::{self, CopyError}; --use crate::exec_start::{default_user_row, parse_passwd, read_database}; - use crate::state::AppState; - - /// The two typed shapes in this module, re-exported from their original paths. The -@@ -144,116 +123,6 @@ fn insufficient_storage(path: &Path, reading: disk::Reading) -> Response { - .into_response() - } - --/// The owner of an entry an upload creates: the image's default user's uid and primary gid. --/// `None` in place of one, throughout this module, leaves an entry the daemon's, which is --/// root. --#[derive(Clone, Copy, Debug, Eq, PartialEq)] --struct Owner { -- uid: u32, -- gid: u32, --} -- --impl Owner { -- /// Gives the entry `fd` refers to this owner. `fd` may be `O_PATH`: with an empty path -- /// and `AT_EMPTY_PATH`, `fchownat` changes the descriptor's own inode, a symlink's -- /// included. -- fn apply(self, fd: BorrowedFd<'_>) -> Result<(), Errno> { -- fchownat( -- fd, -- "", -- Some(Uid::from_raw(self.uid)), -- Some(Gid::from_raw(self.gid)), -- AtFlags::AT_EMPTY_PATH, -- ) -- } --} -- --/// The owner of what this upload creates, or the detail of the `400 unknown_user` that --/// refuses it. --/// --/// Resolved at each upload from the guest's `/etc/passwd`, the way a start resolves the --/// default, so a user an earlier exec created owns the next upload. A default with no row --/// is refused here, before the body is read, because extracting it as root would leave --/// the default user a tree it can't write. The `Err` is the detail rather than a --/// `Response` so the variant stays small (clippy's `result_large_err`); --/// [`unknown_default_user`] builds the answer. --fn upload_owner(state: &AppState) -> Result, String> { -- let config = state.config(); -- let Some(name) = config.default_user.as_deref() else { -- return Ok(None); -- }; -- let rows = parse_passwd(&read_database(&config.passwd_path)); -- match default_user_row(name, &rows) { -- Ok(row) => Ok(Some(Owner { -- uid: row.uid, -- gid: row.gid, -- })), -- Err(why) => { -- let detail = format!("an upload's new entries belong to the default user, and {why}"); -- Err(detail) -- } -- } --} -- --/// The `400 unknown_user` that refuses an upload under a default with no passwd row. The --/// body is the exec routes' JSON `ErrorBody`, since the slug is theirs, where this module's --/// other refusals are plain text. --fn unknown_default_user(detail: String) -> Response { -- tracing::warn!(detail, "upload refused"); -- ( -- StatusCode::BAD_REQUEST, -- Json(ErrorBody { -- error: Cow::Borrowed(ERROR_UNKNOWN_USER), -- detail, -- }), -- ) -- .into_response() --} -- --/// Opens the directory `path`, creating each missing component as `create_dir_all` would, --/// and gives each component it creates to `owner`. --/// --/// Walked a component at a time from `/`, or from the working directory for a relative --/// path, each step relative to the descriptor of the last. A component that already exists --/// is followed wherever it leads, symlinks included, as `create_dir_all` follows them, --/// because the path is the caller's. A component this walk creates is opened with --/// [`Confined::RESOLVE`] and chowned through that descriptor, so a symlink put in its --/// place after the `mkdirat` stops the walk instead of taking the chown. --fn open_dirs_owned(path: &Path, owner: Owner) -> io::Result { -- let open = |dir: BorrowedFd<'_>, name: &OsStr, resolve: ResolveFlag| { -- let how = OpenHow::new().flags(Confined::DIRECTORY).resolve(resolve); -- openat2(dir, name, how) -- }; -- let start = if path.is_absolute() { "/" } else { "." }; -- let mut current = open( -- AT_FDCWD, -- OsStr::new(start), -- ResolveFlag::RESOLVE_NO_MAGICLINKS, -- )?; -- for component in path.components() { -- let name = match component { -- Component::Normal(name) => name, -- Component::ParentDir => OsStr::new(".."), -- Component::RootDir | Component::CurDir | Component::Prefix(_) => continue, -- }; -- let created = match mkdirat(¤t, name, Mode::from_bits_truncate(0o777)) { -- Ok(()) => true, -- Err(Errno::EEXIST) => false, -- Err(errno) => return Err(errno.into()), -- }; -- let resolve = if created { -- Confined::RESOLVE -- } else { -- ResolveFlag::RESOLVE_NO_MAGICLINKS -- }; -- current = open(current.as_fd(), name, resolve)?; -- if created { -- owner.apply(current.as_fd())?; -- } -- } -- Ok(current) --} -- - /// Why an archive or member was refused. - /// - /// The refused member's name travels with the refusal so the 400 can name it. A -@@ -427,9 +296,6 @@ fn resolve_member(root: &Path, path: &Path) -> Landing { - /// read from or written to even if the descriptor leaks somewhere it should not. - struct Confined { - root: OwnedFd, -- /// Who owns each entry the extraction creates (see the module comment). `None` leaves -- /// them the daemon's. -- owner: Option, - } - - impl Confined { -@@ -463,19 +329,7 @@ impl Confined { - .union(ResolveFlag::RESOLVE_NO_SYMLINKS) - .union(ResolveFlag::RESOLVE_NO_MAGICLINKS); - -- /// The open flags of every directory on a walk: a path for `*at` calls only, which -- /// fails unless the name is a directory. -- const DIRECTORY: OFlag = OFlag::O_CLOEXEC -- .union(OFlag::O_PATH) -- .union(OFlag::O_DIRECTORY); -- -- /// The open flags of a symlink opened as itself, for its chown ([`Confined::open_link`]). -- const LINK: OFlag = OFlag::O_CLOEXEC -- .union(OFlag::O_PATH) -- .union(OFlag::O_NOFOLLOW); -- -- /// Opens `root`, creating it if it is absent, and holds it. With an `owner`, each -- /// directory created on the way to `root` is that owner's ([`open_dirs_owned`]). -+ /// Opens `root`, creating it if it is absent, and holds it. - /// - /// The root itself is opened by absolute path with only - /// `RESOLVE_NO_MAGICLINKS`, not with [`Self::RESOLVE`]. `RESOLVE_BENEATH` -@@ -493,18 +347,13 @@ impl Confined { - /// and `File::create` this replaced: falling back would mean the confinement a - /// caller cannot see silently became the weaker one, and issue #15 is what the - /// weaker one allows. -- fn open(root: &Path, owner: Option) -> io::Result { -- let root = match owner { -- Some(owner) => open_dirs_owned(root, owner)?, -- None => { -- std::fs::create_dir_all(root)?; -- let how = OpenHow::new() -- .flags(Self::DIRECTORY) -- .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); -- openat2(AT_FDCWD, root, how).map_err(io::Error::from)? -- } -- }; -- Ok(Self { root, owner }) -+ fn open(root: &Path) -> io::Result { -+ std::fs::create_dir_all(root)?; -+ let how = OpenHow::new() -+ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) -+ .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS); -+ let root = openat2(AT_FDCWD, root, how).map_err(io::Error::from)?; -+ Ok(Self { root }) - } - - /// Opens the single component `name` under `dir` as a directory. -@@ -514,7 +363,9 @@ impl Confined { - /// the error name which component failed, and a 400 that names the component - /// is the difference between an operator fixing their tree and re-reading it. - fn open_dir(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Result { -- let how = OpenHow::new().flags(Self::DIRECTORY).resolve(Self::RESOLVE); -+ let how = OpenHow::new() -+ .flags(OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC) -+ .resolve(Self::RESOLVE); - openat2(dir, name, how) - } - -@@ -524,8 +375,7 @@ impl Confined { - /// not an error: an archive may name the same directory twice, and a partial - /// extraction that is retried has to converge. The open that follows decides - /// whether the existing name is usable, and a symlink there fails with -- /// `ELOOP` rather than being traversed. A directory this walk creates is given the -- /// extraction's owner through that descriptor; one that existed keeps its own. -+ /// `ELOOP` rather than being traversed. - fn make_dirs(&self, parts: &[OsString]) -> Result { - let mut current = self.root.try_clone().map_err(|err| WalkError { - component: None, -@@ -536,33 +386,17 @@ impl Confined { - // replayed by `apply_deferred_modes` after every member has landed. - // Applying it here is the defect that pass exists to avoid: a 0o500 - // directory blocks the writes of its own children. -- let created = match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) -- { -- Ok(()) => true, -- Err(Errno::EEXIST) => false, -+ match mkdirat(¤t, part.as_os_str(), Mode::from_bits_truncate(0o777)) { -+ Ok(()) | Err(Errno::EEXIST) => {} - Err(errno) => return Err(WalkError::at(parts, index, errno)), -- }; -+ } - current = self - .open_dir(current.as_fd(), part) - .map_err(|errno| WalkError::at(parts, index, errno))?; -- if created { -- self.own(current.as_fd(), None) -- .map_err(|errno| WalkError::at(parts, index, errno))?; -- } - } - Ok(current) - } - -- /// Gives an entry this extraction created its owner: `previous`, the owner of the entry -- /// it replaced, when there was one, and the extraction's otherwise. Nothing without an -- /// extraction owner, so an unset default leaves every entry the daemon's. -- fn own(&self, fd: BorrowedFd<'_>, previous: Option) -> Result<(), Errno> { -- match self.owner { -- Some(owner) => previous.unwrap_or(owner).apply(fd), -- None => Ok(()), -- } -- } -- - /// Splits `parts` into the parent directory descriptor and the final name. - /// - /// Every leaf creation needs exactly this: a `dirfd` the kernel has already -@@ -594,13 +428,9 @@ impl Confined { - fn create_file(&self, parts: &[OsString]) -> Result { - let (parent, name) = self.parent_of(parts)?; - let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); -+ self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) - .map_err(|errno| WalkError::at(parts, last, errno))?; -- // Through the descriptor `O_EXCL` created, which is this file whatever the name -- // holds by now. -- self.own(fd.as_fd(), previous) -- .map_err(|errno| WalkError::at(parts, last, errno))?; - Ok(std::fs::File::from(fd)) - } - -@@ -628,34 +458,9 @@ impl Confined { - fn create_symlink(&self, parts: &[OsString], target: &Path) -> Result<(), WalkError> { - let (parent, name) = self.parent_of(parts)?; - let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); -+ self.replace(parent.as_fd(), &name); - symlinkat(target, parent.as_fd(), name.as_os_str()) -- .map_err(|errno| WalkError::at(parts, last, errno))?; -- if self.owner.is_some() { -- let link = Self::open_link(parent.as_fd(), &name) -- .map_err(|errno| WalkError::at(parts, last, errno))?; -- self.own(link.as_fd(), previous) -- .map_err(|errno| WalkError::at(parts, last, errno))?; -- } -- Ok(()) -- } -- -- /// Opens the symlink `name` under `dir` itself, `O_PATH` and without following it, for -- /// its chown. -- /// -- /// The name is opened rather than chowned in place, and the result has to be a -- /// symlink, because a process that can write `dir` can swap the link for a hard link -- /// to another file between the `symlinkat` and the chown, and a chown by name would then -- /// hand that file to the default user. `EEXIST` says the name no longer holds the link. -- fn open_link(dir: BorrowedFd<'_>, name: &OsStr) -> Result { -- let how = OpenHow::new().flags(Self::LINK).resolve(Self::RESOLVE); -- let link = openat2(dir, name, how)?; -- let kind = SFlag::from_bits_truncate(fstat(&link)?.st_mode) & SFlag::S_IFMT; -- if kind == SFlag::S_IFLNK { -- Ok(link) -- } else { -- Err(Errno::EEXIST) -- } -+ .map_err(|errno| WalkError::at(parts, last, errno)) - } - - /// Creates a hard link at `parts` pointing at `source`, an archive-root-relative -@@ -674,7 +479,6 @@ impl Confined { - ) -> Result<(), HardLinkError> { - let (parent, name) = self.parent_of(parts).map_err(HardLinkError::Dest)?; - let last = parts.len() - 1; -- // No chown: the link names an inode that already exists, whose owner it keeps. - self.replace(parent.as_fd(), &name); - - // An empty source is the archive naming the root as a hard-link target. -@@ -720,25 +524,14 @@ impl Confined { - Ok(current) - } - -- /// Unlinks `name` under `dir`, ignoring every failure, and returns the owner of what -- /// was there when the extraction gives its entries an owner, so the new entry can keep -- /// it. -+ /// Unlinks `name` under `dir`, ignoring every failure. - /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is - /// `ENOENT`, which means there was nothing to replace, and any other means the -- /// create below fails and reports the real reason. The owner is read without following -- /// a symlink, so a replaced link reports its own. -- fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) -> Option { -- let previous = self -- .owner -- .and_then(|_| fstatat(dir, name, AtFlags::AT_SYMLINK_NOFOLLOW).ok()) -- .map(|stat| Owner { -- uid: stat.st_uid, -- gid: stat.st_gid, -- }); -+ /// create below fails and reports the real reason. -+ fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { - let _ = unlinkat(dir, name, UnlinkatFlags::NoRemoveDir); -- previous - } - - /// Applies `mode` to `parts`, without following a symlink at the last component. -@@ -853,11 +646,10 @@ pub fn fuzz_extract( - probe: disk::available_bytes, - reserve: 0, - }; -- extract_into(root, archive, caps, &guard, None).map_err(|refusal| format!("{refusal:?}")) -+ extract_into(root, archive, caps, &guard).map_err(|refusal| format!("{refusal:?}")) - } - --/// Extracts `archive` into `root`, enforcing the data-filter contract, with each entry it --/// creates given to `owner` when there is one (see the module comment). -+/// Extracts `archive` into `root`, enforcing the data-filter contract. - /// - /// Synchronous on purpose: `tar`'s reader is blocking, so callers hand this to - /// `spawn_blocking` rather than pretending it is async. -@@ -866,18 +658,17 @@ fn extract_into( - archive: impl Read, - caps: Caps, - guard: &disk::Guard, -- owner: Option, - ) -> Result { - // The kernel's half of the confinement, held for the whole extraction. Every - // member below is created through this rather than by absolute path, so a - // component that turns out to be a symlink stops the write instead of - // redirecting it. See the module comment. -- let confined = Confined::open(root, owner)?; -+ let confined = Confined::open(root)?; - - let mut ar = tar::Archive::new(archive); -- // The archive's ownership is dropped for the same reason CPython's `data` filter -- // drops it: its uid/gid are the packing host's, meaningless here, and honoring them -- // would need privilege we should not spend. Who owns an entry is `owner`'s to say. -+ // Ownership is dropped for the same reason CPython's `data` filter drops it: -+ // the archive's uid/gid are the packing host's, meaningless here, and honoring -+ // them would need privilege we should not spend. - ar.set_preserve_permissions(false); - ar.set_preserve_ownerships(false); - ar.set_unpack_xattrs(false); -@@ -1404,8 +1195,7 @@ fn sliced_stream( - }) - } - --/// Writes one file. Not confined to a root; see the module comment, which also says who --/// owns the file and the parents this creates. -+/// Writes one file. Not confined to a root; see the module comment. - pub async fn write_file(State(state): State, request: Request) -> Response { - let query = match fs_query(&request) { - Ok(query) => query, -@@ -1432,13 +1222,6 @@ pub async fn write_file(State(state): State, request: Request) -> Resp - }, - }; - -- // Like the mode, before anything lands: a default user with no passwd row refuses the -- // write rather than leaving it a root-owned file. -- let owner = match upload_owner(&state) { -- Ok(owner) => owner, -- Err(detail) => return unknown_default_user(detail), -- }; -- - // Checked before the parent directory is created and before the file is - // opened, so a refused write leaves nothing behind — not even an empty file - // where the caller's data was supposed to go. An empty file at the target path -@@ -1447,61 +1230,24 @@ pub async fn write_file(State(state): State, request: Request) -> Resp - return insufficient_storage(&path, reading); - } - -- if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { -- let created = match owner { -- None => tokio::fs::create_dir_all(parent).await, -- Some(owner) => { -- let parent = parent.to_path_buf(); -- tokio::task::spawn_blocking(move || open_dirs_owned(&parent, owner).map(drop)) -- .await -- .unwrap_or_else(|err| Err(io::Error::other(err))) -- } -- }; -- if let Err(err) = created { -- tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); -- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); -- } -+ if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) -+ && let Err(err) = tokio::fs::create_dir_all(parent).await -+ { -+ tracing::warn!(path = %path.display(), %err, "cannot create parent directory"); -+ return (StatusCode::INTERNAL_SERVER_ERROR, "cannot create parent").into_response(); - } - -- let options = |create_new: bool| { -- let mut options = tokio::fs::OpenOptions::new(); -- options.write(true).truncate(true); -- if create_new { -- options.create_new(true); -- } else { -- options.create(true); -- } -- if let Some(mode) = mode { -- // Set at open, so the file never exists with a wider mode than asked for -- // even briefly. A separate chmod after writing leaves that window open, -- // which matters when the file is a credential. -- options.mode(mode); -- } -- options -- }; -- // With an owner to give, `O_EXCL` first, so the daemon knows whether this write -- // created the file: one it only truncates keeps its owner. -- let opened = match owner { -- None => options(false).open(&path).await.map(|file| (file, false)), -- // The usual failure is that the file exists, which the plain open truncates; any -- // other, the plain open meets again and reports. -- Some(_) => match options(true).open(&path).await { -- Ok(file) => Ok((file, true)), -- Err(_) => options(false).open(&path).await.map(|file| (file, false)), -- }, -- }; -- let mut file = match opened { -- Ok((file, created)) => { -- if let (true, Some(owner)) = (created, owner) -- && let Err(errno) = owner.apply(file.as_fd()) -- { -- // The file is new and empty, and nobody was told it exists. -- let _ = tokio::fs::remove_file(&path).await; -- tracing::warn!(path = %path.display(), %errno, "cannot give the file its owner"); -- return (StatusCode::INTERNAL_SERVER_ERROR, "cannot set owner").into_response(); -- } -- file -- } -+ let mut options = tokio::fs::OpenOptions::new(); -+ options.write(true).create(true).truncate(true); -+ if let Some(mode) = mode { -+ // Set at open, so the file never exists with a wider mode than asked for -+ // even briefly. A separate chmod after writing leaves that window open, -+ // which matters when the file is a credential. -+ options.mode(mode); -+ } -+ -+ let mut file = match options.open(&path).await { -+ Ok(file) => file, - Err(err) => { - tracing::warn!(path = %path.display(), %err, "cannot open file for write"); - return (StatusCode::INTERNAL_SERVER_ERROR, "cannot write file").into_response(); -@@ -1718,13 +1464,6 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo - .into_response(); - } - -- // Before the body is spooled, so a default user with no passwd row refuses the upload -- // without receiving it and before any member lands. -- let owner = match upload_owner(&state) { -- Ok(owner) => owner, -- Err(detail) => return unknown_default_user(detail), -- }; -- - let caps = Caps { - max_members: state.config().max_tar_members, - max_bytes: state.config().max_tar_bytes, -@@ -1755,7 +1494,7 @@ pub async fn write_tar(State(state): State, request: Request) -> Respo - }; - - let extracted = tokio::task::spawn_blocking(move || { -- extract_into(&root, spool, caps, &guard, owner).map(|n| (root, n)) -+ extract_into(&root, spool, caps, &guard).map(|n| (root, n)) - }) - .await; - diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs index 1549ac9..b30f398 100644 --- a/crates/agentd/src/hook_handlers.rs @@ -823,104 +284,22 @@ index 1549ac9..b30f398 100644 //! over loopback from inside the guest, so any process in the VM can make a handler //! run: handlers must be safe to run spuriously. The hook log's cap bounds how many //! times that can happen. -diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..eccaf3c 100644 ---- a/crates/agentd/src/schema.rs -+++ b/crates/agentd/src/schema.rs -@@ -602,12 +602,7 @@ pub const FS_READ_FILE: &[Status] = &[ - ]; - - pub const FS_WRITE_FILE: &[Status] = &[ -- Status::new( -- StatusCode::NO_CONTENT, -- "", -- "written, with the mode applied. With AGENTD_DEFAULT_USER set, a file or \ -- parent directory the write creates belongs to the default user.", -- ), -+ Status::new(StatusCode::NO_CONTENT, "", "written, with the mode applied"), - Status::new( - StatusCode::BAD_REQUEST, - "", -@@ -615,7 +610,6 @@ pub const FS_WRITE_FILE: &[Status] = &[ - mode. Checked before a byte lands, so a rejected mode leaves nothing \ - behind.", - ), -- UPLOAD_UNKNOWN_USER, - UNAUTHORIZED, - NOT_BOOTSTRAPPED, - BODY_TOO_LARGE, -@@ -623,8 +617,7 @@ pub const FS_WRITE_FILE: &[Status] = &[ - Status::new( - StatusCode::INTERNAL_SERVER_ERROR, - "", -- "the parent could not be created, the write or chmod failed, or a file the \ -- write created could not be given the default user, in which case it is removed", -+ "the parent could not be created, or the write or chmod failed", - ), - ]; - -@@ -661,24 +654,8 @@ pub const FS_READ_TAR: &[Status] = &[ - ), - ]; - --/// The refusal both upload routes share when the image's default user has no passwd row. --/// Unlike the routes' other refusals, its body is the JSON `ErrorBody`, since the slug is --/// the one a start answers. --const UPLOAD_UNKNOWN_USER: Status = Status::new( -- StatusCode::BAD_REQUEST, -- "unknown_user", -- "AGENTD_DEFAULT_USER names no row of the guest's /etc/passwd, by name or by uid, so \ -- nothing the upload creates could belong to it. Checked before the body is read; \ -- nothing was written, and detail names the value and the variable.", --); -- - pub const FS_WRITE_TAR: &[Status] = &[ -- Status::new( -- StatusCode::NO_CONTENT, -- "", -- "extracted under ?path=. With AGENTD_DEFAULT_USER set, each entry the extraction \ -- creates belongs to the default user.", -- ), -+ Status::new(StatusCode::NO_CONTENT, "", "extracted under ?path="), - Status::new( - StatusCode::BAD_REQUEST, - "", -@@ -687,7 +664,6 @@ pub const FS_WRITE_TAR: &[Status] = &[ - path, an absolute or out-of-tree link target, or a device or fifo member. \ - The refused member's name is in the body.", - ), -- UPLOAD_UNKNOWN_USER, - UNAUTHORIZED, - NOT_BOOTSTRAPPED, - Status::new( diff --git a/crates/microvms-app/src/agents/mod.rs b/crates/microvms-app/src/agents/mod.rs -index f7ca208..6ca3bcc 100644 +index 333dfae..6ca3bcc 100644 --- a/crates/microvms-app/src/agents/mod.rs +++ b/crates/microvms-app/src/agents/mod.rs -@@ -16,8 +16,7 @@ - //! 2. **The daemon's execs start from an empty environment**, so the agent's subshells - //! find no `PATH` and every command exits 127. The environment file sets it. - //! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent --//! cannot read, unless the image sets `AGENTD_DEFAULT_USER`. One root `chown` follows the --//! uploads either way. -+//! cannot read. One root `chown` follows the uploads. - //! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses - //! wire API, with an inference-profile model id and hosted web search disabled. Its - //! config file names that host for the launch region, and its credential comes from -@@ -492,10 +491,8 @@ pub fn provisioning_files( - +@@ -492,9 +492,7 @@ pub fn provisioning_files( /// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5). /// --/// The daemon writes an upload as root unless the image sets `AGENTD_DEFAULT_USER`, so the --/// `chown` is not optional and it runs as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming + /// The daemon writes every upload as root, so the `chown` is not optional and it runs +-/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming -/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user -/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call -+/// The daemon writes every upload as root, so the `chown` is not optional and it runs +/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call /// with a fresh token overwrites the same files, which is how a 12-hour token is /// refreshed on a long-lived VM (AGENT-8). pub async fn install_access( -@@ -513,7 +510,6 @@ pub async fn install_access( +@@ -512,7 +510,6 @@ pub async fn install_access( vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")], ) .with_shell(true) @@ -947,15 +326,15 @@ index e83657c..16a4ab8 100644 /// /// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session diff --git a/crates/microvms-app/src/workspace.rs b/crates/microvms-app/src/workspace.rs -index f6c4505..f3d4bd5 100644 +index 11db62c..f3d4bd5 100644 --- a/crates/microvms-app/src/workspace.rs +++ b/crates/microvms-app/src/workspace.rs @@ -367,11 +367,8 @@ impl Session { .map(String::from) .chain(doomed) .collect(); -- // As root, by uid: a deleted path can sit in a directory root owns, `/workspace` -- // itself among them, and an image's default user would be refused deleting it. +- // As root, by uid: the daemon wrote the tree as root, and an image's default user +- // would be refused deleting it. let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command) .with_cwd(Some(REMOTE_WORKDIR.into())) - .with_user(Some(crate::session::ROOT_UID.into())) diff --git a/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch b/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch +++ b/verify/guards/faults/f2p-output-collection-and-surviving-group-members-share-the.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch b/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch +++ b/verify/guards/faults/f2p-output-collection-finishes-when-a-detached-writer-keeps.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch b/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch +++ b/verify/guards/faults/f2p-rejected-body-drain-has-a-deadline-and-closes-the.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch b/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch +++ b/verify/guards/faults/f2p-replacing-a-file-preserves-its-other-hard-links.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch b/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch +++ b/verify/guards/faults/f2p-run-hook-body-collection-has-a-deadline.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@ diff --git a/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch b/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch index 6d0489dc..a0680e4e 100644 --- a/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch +++ b/verify/guards/faults/f2p-run-hook-envelopes-are-bounded-before-parsing.patch @@ -60,10 +60,9 @@ index 32a57e4..28a5a8e 100644 #[cfg(test)] diff --git a/crates/agentd/src/fs.rs b/crates/agentd/src/fs.rs -index 1dd6a70..aa34449 100644 --- a/crates/agentd/src/fs.rs +++ b/crates/agentd/src/fs.rs -@@ -569,21 +569,26 @@ impl Confined { +@@ -419,17 +419,22 @@ /// Creates and opens the file a data member's bytes go into. /// @@ -79,7 +78,7 @@ index 1dd6a70..aa34449 100644 fn create_file(&self, parts: &[OsString]) -> Result { let (parent, name) = self.parent_of(parts)?; let last = parts.len() - 1; -- let previous = self.replace(parent.as_fd(), &name); +- self.replace(parent.as_fd(), &name); - let fd = Self::open_new_file(parent.as_fd(), name.as_os_str()) + let how = OpenHow::new() + .flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_CLOEXEC) @@ -89,16 +88,11 @@ index 1dd6a70..aa34449 100644 + .resolve(Self::RESOLVE); + let fd = openat2(parent.as_fd(), name.as_os_str(), how) .map_err(|errno| WalkError::at(parts, last, errno))?; - // Through the descriptor `O_EXCL` created, which is this file whatever the name - // holds by now. -- self.own(fd.as_fd(), previous) -+ self.own(fd.as_fd(), None) - .map_err(|errno| WalkError::at(parts, last, errno))?; Ok(std::fs::File::from(fd)) } -@@ -710,8 +715,9 @@ impl Confined { - /// was there when the extraction gives its entries an owner, so the new entry can keep - /// it. +@@ -526,8 +531,9 @@ + + /// Unlinks `name` under `dir`, ignoring every failure. /// - /// A file or link member replaces the directory entry before creation, so - /// other names for an existing inode keep their contents and permissions. Every error is ignored on purpose: the common one is @@ -106,9 +100,9 @@ index 1dd6a70..aa34449 100644 + /// `symlinkat`/`linkat` have no truncating equivalent of `O_TRUNC`, so the old + /// name has to go first. Every error is ignored on purpose: the common one is /// `ENOENT`, which means there was nothing to replace, and any other means the - /// create below fails and reports the real reason. The owner is read without following - /// a symlink, so a replaced link reports its own. -@@ -997,9 +1003,6 @@ fn extract_into( + /// create below fails and reports the real reason. + fn replace(&self, dir: BorrowedFd<'_>, name: &OsStr) { +@@ -802,9 +808,6 @@ ), })?; } @@ -119,10 +113,10 @@ index 1dd6a70..aa34449 100644 // Regular, contiguous, and anything else carrying data. diff --git a/crates/agentd/src/hook_handlers.rs b/crates/agentd/src/hook_handlers.rs -index 1549ac9..f8b276f 100644 +index 1e4b63c..b7dcc5a 100644 --- a/crates/agentd/src/hook_handlers.rs +++ b/crates/agentd/src/hook_handlers.rs -@@ -126,37 +126,28 @@ pub async fn execute( +@@ -124,37 +124,28 @@ pub async fn execute( Err(error) => return failed(format!("spawn failed: {error}")), }; let pid = child.id(); @@ -180,7 +174,7 @@ index 1549ac9..f8b276f 100644 if !stdout.is_empty() || !stderr.is_empty() { tracing::info!(hook, stdout = %stdout, stderr = %stderr, "hook handler output"); } -@@ -184,15 +175,17 @@ fn check_executable(path: &Path) -> Result<(), String> { +@@ -182,15 +173,17 @@ fn check_executable(path: &Path) -> Result<(), String> { /// Reads a stream to its end, keeping the first [`LOGGED_OUTPUT_BYTES`]. The rest /// is drained so a chatty handler never blocks on a full pipe. @@ -202,7 +196,7 @@ index 1549ac9..f8b276f 100644 /// The handler path for `hook` under `dir`, for documentation and tests. diff --git a/crates/agentd/src/routes.rs b/crates/agentd/src/routes.rs -index 6ea13ec..0af9290 100644 +index 6e50bf2..126fbb5 100644 --- a/crates/agentd/src/routes.rs +++ b/crates/agentd/src/routes.rs @@ -46,15 +46,15 @@ pub fn app(state: AppState) -> Router { @@ -321,10 +315,10 @@ index 6ea13ec..0af9290 100644 /// Maps a documented endpoint onto the handler that serves it. diff --git a/crates/agentd/src/schema.rs b/crates/agentd/src/schema.rs -index 79f5411..6c4aef4 100644 +index d06a170..3cab117 100644 --- a/crates/agentd/src/schema.rs +++ b/crates/agentd/src/schema.rs -@@ -750,26 +750,14 @@ pub const SCHEMA: &[Status] = &[Status::new( +@@ -716,26 +716,14 @@ pub const SCHEMA: &[Status] = &[Status::new( it has a token, and none of it is secret.", )]; @@ -357,7 +351,6 @@ index 79f5411..6c4aef4 100644 StatusCode::OK, "", diff --git a/crates/agentd/src/serve.rs b/crates/agentd/src/serve.rs -index cb46a54..18bfa48 100644 --- a/crates/agentd/src/serve.rs +++ b/crates/agentd/src/serve.rs @@ -1,74 +1,34 @@