diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index 4e2e867..e64b524 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -94,12 +94,14 @@ pub enum PrivMode { Mdo, /// Setuid helper that performs only the jail spawn — deployed hosts. Helper, + /// `sudo -n ` — hosts with sudo configured (no password prompt). + Sudo, /// No escalation: run the jail command directly. None, } impl PrivMode { - /// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `none`). + /// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `sudo` | `none`). /// Defaults to `Mdo` (the live-USB posture); only consulted when a spawn /// actually requests a jail. pub fn from_env() -> Self { @@ -110,6 +112,7 @@ impl PrivMode { .as_str() { "helper" => PrivMode::Helper, + "sudo" => PrivMode::Sudo, "none" => PrivMode::None, _ => PrivMode::Mdo, } @@ -169,6 +172,11 @@ fn priv_wrap( a.extend(argv); ("mdo".to_string(), a) } + PrivMode::Sudo => { + let mut a = vec!["-n".to_string(), exe]; + a.extend(argv); + ("sudo".to_string(), a) + } PrivMode::Helper => { let mut a = vec![exe]; a.extend(argv); @@ -247,6 +255,54 @@ async fn remove_staged_dir(path: &Path) { let _ = tokio::fs::remove_dir_all(path).await; } +async fn spawn_prepared_child( + prepared: &PreparedSpawnCommand, + agent_config: &AgentSpawnConfig, +) -> Result<(Child, Option), SpawnerError> { + let mut cmd = Command::new(&prepared.program); + cmd.args(&prepared.argv) + .envs(&prepared.env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if agent_config.jail.is_none() { + if let Some(ref dir) = agent_config.working_dir { + cmd.current_dir(dir); + } + } + + let mut child = cmd.spawn().map_err(SpawnerError::Io)?; + + // Brief grace period for the process to start. + tokio::time::sleep(Duration::from_millis(500)).await; + + // Check it has not immediately crashed. + match child.try_wait() { + Ok(Some(status)) => { + let stderr = if let Some(ref mut stderr) = child.stderr.take() { + use tokio::io::AsyncReadExt; + let mut buf = Vec::new(); + let _ = stderr.read_to_end(&mut buf).await; + String::from_utf8_lossy(&buf).to_string() + } else { + String::new() + }; + if let Some(path) = prepared.cleanup_dir.as_deref() { + remove_staged_dir(path).await; + } + Err(SpawnerError::Io(std::io::Error::other(format!( + "process exited immediately with status {status}: {stderr}" + )))) + } + Ok(None) => { + let stdout = child.stdout.take(); + Ok((child, stdout)) + } + Err(e) => Err(SpawnerError::Io(e)), + } +} + #[allow(clippy::too_many_arguments)] pub async fn prepare_spawn_command( binary: &str, @@ -679,50 +735,7 @@ impl Spawner { ); let _startup_timeout = agent_config.startup_timeout_secs; - let op = || async { - let mut cmd = Command::new(&prepared.program); - cmd.args(&prepared.argv) - .envs(&prepared.env) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - if agent_config.jail.is_none() { - if let Some(ref dir) = agent_config.working_dir { - cmd.current_dir(dir); - } - } - - let mut child = cmd.spawn().map_err(SpawnerError::Io)?; - - // Brief grace period for the process to start - tokio::time::sleep(Duration::from_millis(500)).await; - - // Check it hasn't immediately crashed - match child.try_wait() { - Ok(Some(status)) => { - let stderr = if let Some(ref mut stderr) = child.stderr.take() { - use tokio::io::AsyncReadExt; - let mut buf = Vec::new(); - let _ = stderr.read_to_end(&mut buf).await; - String::from_utf8_lossy(&buf).to_string() - } else { - String::new() - }; - if let Some(path) = prepared.cleanup_dir.as_deref() { - remove_staged_dir(path).await; - } - Err(SpawnerError::Io(std::io::Error::other(format!( - "process exited immediately with status {status}: {stderr}" - )))) - } - Ok(None) => { - let stdout = child.stdout.take(); - Ok((child, stdout)) - } - Err(e) => Err(SpawnerError::Io(e)), - } - }; + let op = || spawn_prepared_child(&prepared, &agent_config); let backoff = ExponentialBuilder::default() .with_min_delay(Duration::from_millis(500)) @@ -820,6 +833,28 @@ mod jail_tests { assert_eq!(a, argv(&["-u", "root", "jexec", "pi0", "pi", "-p", "task"])); } + #[test] + fn named_jail_via_sudo() { + let j = JailConfig { + name: Some("proof0".into()), + root_path: Some("/usr/local/bastille/jails/proof0/root".into()), + user: Some("clawdie".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap( + "pi", + &argv(&["-p", "task"]), + Some(&j), + &PrivMode::Sudo, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(exe, "sudo"); + assert_eq!( + a, + argv(&["-n", "jexec", "-U", "clawdie", "proof0", "pi", "-p", "task"]) + ); + } + #[test] fn named_jail_with_user_no_priv() { let j = JailConfig { diff --git a/docs/CLAWDIE-INSTALLER-HANDOFF.md b/docs/CLAWDIE-INSTALLER-HANDOFF.md index 06d6310..5c5580c 100644 --- a/docs/CLAWDIE-INSTALLER-HANDOFF.md +++ b/docs/CLAWDIE-INSTALLER-HANDOFF.md @@ -106,9 +106,14 @@ Not done: no destructive `apply --yes`; still requires scratch pool/VM. - [x] `cargo test -p clawdie` passes on FreeBSD 15 (output + versions reported). - [x] `discover` + `plan` correct against a real FreeBSD ZFS host for read-only/dry-run paths. -- [ ] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as specified; teardown verified. -- [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard refuses non-empty disks. -- [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR and reported back. +- [x] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as + specified; teardown verified. (OSA file-backed testpool, 2026-06-21.) +- [x] Idempotent re-run: second `apply --yes` skips user creation (exit 65 = already + exists), rc.d + sysrc overwrite cleanly. +- [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard + refuses non-empty disks. +- [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR + and reported back. ## Platform notes diff --git a/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md b/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md index 1c8b15b..0002f37 100644 --- a/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md +++ b/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md @@ -54,6 +54,10 @@ just `jexec`. We choose the escalation per host via `PrivMode` root is a real escalation surface, so use a narrow setuid helper (`/usr/local/libexec/colibri-jail-spawn`) that only performs the jail spawn, and keep the daemon unprivileged. +- **Validated hosts with existing sudo policy → `sudo`.** `sudo -n` can be used + as an interim proof/ops mode when a narrow sudoers rule already permits the + daemon user to run the jail command without prompting. Prefer the setuid helper + for long-lived production hosts once packaged. - **`none`** — run the jail command directly (already root, or tests). ## Staged env payloads