From 78be056b62868065915f97991c26e58abfbcc9fc Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sun, 21 Jun 2026 17:10:47 +0200 Subject: [PATCH] fix(spawner): resolve privileged wrappers to absolute paths + log spawn context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jail spawn path launches its wrapper by bare name (sudo / jexec / mdo) and relies on execvp + the daemon's inherited PATH. Under daemon(8)/rc the PATH is often empty or reordered, so execvp either misses the binary (ENOENT) or hits a non-executable same-named entry first and returns EACCES — the spawn "Permission denied" seen on FreeBSD even though the identical command runs from a shell. - resolve_program() absolutizes a bare program name against a fixed search list (first regular executable wins), leaving slash-bearing paths untouched and falling back to the bare name so the OS still reports a real error. - spawn_prepared_child now logs the resolved program, requested name, full argv, and PATH before spawning. The previous "attempting spawn" log carried no spawn-context detail, which is why the failure was opaque. This removes the PATH-search EACCES as a variable so a truss/ktrace run can attribute any remaining denial to an actual kernel/MAC policy instead. Tests: resolve_program pass-through, absolutization, and missing-name fallback. Co-Authored-By: Claude Opus 4.8 --- crates/colibri-daemon/src/spawner.rs | 68 +++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index e64b524..a0c1d3a 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -185,6 +185,41 @@ fn priv_wrap( } } +/// Directories searched to resolve a bare wrapper name (`sudo`, `jexec`, `mdo`) +/// to an absolute path. A daemon must not depend on an inherited `PATH` for its +/// privileged wrappers: under `daemon(8)`/rc the `PATH` is often empty or +/// reordered, so `execvp` either misses the binary (`ENOENT`) or hits a +/// non-executable same-named entry first and returns `EACCES` — the exact spawn +/// failure seen on FreeBSD even though the same command runs fine from a shell. +const WRAPPER_SEARCH_DIRS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", +]; + +/// Resolve a program name to an absolute executable path. Names already +/// containing a `/` are returned unchanged. Bare names are looked up in +/// `WRAPPER_SEARCH_DIRS`, returning the first regular executable file found. +/// Falls back to the original name (letting the OS report the error) when +/// nothing matches. +fn resolve_program(program: &str) -> String { + if program.contains('/') { + return program.to_string(); + } + for dir in WRAPPER_SEARCH_DIRS { + let candidate = Path::new(dir).join(program); + if let Ok(meta) = std::fs::metadata(&candidate) { + if meta.is_file() && meta.permissions().mode() & 0o111 != 0 { + return candidate.to_string_lossy().into_owned(); + } + } + } + program.to_string() +} + fn jail_stage_root(jail: &JailConfig) -> Option<&str> { jail.root_path.as_deref().or(jail.path.as_deref()) } @@ -259,7 +294,15 @@ async fn spawn_prepared_child( prepared: &PreparedSpawnCommand, agent_config: &AgentSpawnConfig, ) -> Result<(Child, Option), SpawnerError> { - let mut cmd = Command::new(&prepared.program); + let program = resolve_program(&prepared.program); + info!( + program = %program, + requested = %prepared.program, + args = ?prepared.argv, + path = %std::env::var("PATH").unwrap_or_default(), + "spawning agent subprocess" + ); + let mut cmd = Command::new(&program); cmd.args(&prepared.argv) .envs(&prepared.env) .stdin(Stdio::null()) @@ -788,6 +831,29 @@ mod jail_tests { v.iter().map(|s| s.to_string()).collect() } + #[test] + fn resolve_program_passes_through_absolute_paths() { + assert_eq!(resolve_program("/usr/sbin/jexec"), "/usr/sbin/jexec"); + assert_eq!(resolve_program("./rel/path"), "./rel/path"); + } + + #[test] + fn resolve_program_absolutizes_a_known_bare_name() { + // `sh` exists and is executable on any unix test host. Resolution must + // yield an absolute path under one of the search dirs. + let resolved = resolve_program("sh"); + assert!( + resolved.starts_with('/') && resolved.ends_with("/sh"), + "expected absolute path to sh, got {resolved}" + ); + } + + #[test] + fn resolve_program_falls_back_to_bare_name_when_missing() { + let missing = "definitely-not-a-real-wrapper-xyz"; + assert_eq!(resolve_program(missing), missing); + } + #[test] fn no_jail_returns_command_unchanged() { let (exe, a) = jail_wrap( -- 2.45.3