fix(spawner): resolve privileged wrappers to absolute paths + log spawn context #131

Merged
clawdie merged 1 commit from absolute-spawn-wrappers into main 2026-06-21 17:12:04 +02:00

View file

@ -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<ChildStdout>), 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(