kovra-wrapper 0.9.1

kovra subprocess wrapper — injects resolved secrets into a child process's environment without leaking plaintext (I6/I7).
Documentation
//! Observe the **requesting process** — the parent that launched this kovra
//! process — to populate [`kovra_core::ConfirmRequest::requesting_process`] (I16,
//! §8.3).
//!
//! This is a **trusted, observed fact**: the parent pid comes from the kernel
//! (`getppid`), and the executable name is read from the OS by pid. It is never
//! sourced from untrusted requester input, so it cannot be spoofed by the agent
//! whose request triggered the prompt. The human approving at the Touch ID /
//! file-broker prompt therefore sees *who* is really asking (e.g.
//! `node (pid 1234)`) rather than always "kovra".
//!
//! Why this lives in the wrapper (not core): observing a process is OS work, and
//! `core` must stay free of process-observation logic (CLAUDE.md rule 4). Both
//! the CLI (`kovra show`, private-key ops) and the wrapper (`kovra run`) call
//! [`observe_parent`]; the CLI depends on `kovra-wrapper`, so it reuses this
//! helper rather than duplicating it.
//!
//! Degradation: if the name cannot be read, we fall back to `pid <N>`. We never
//! include anything but a process identity (executable name/path + pid) — no
//! arguments, no environment — so this can never leak a secret value (I7/I12).

/// A human-readable identity for the **parent** process of the current process.
///
/// Returns e.g. `node (pid 1234)` or `/opt/homebrew/bin/node (pid 1234)`, or
/// just `pid 1234` when the executable name cannot be resolved. Returns `None`
/// only if even the parent pid cannot be observed (not expected on supported
/// hosts, but it fails soft rather than fabricating an identity).
#[must_use]
pub fn observe_parent() -> Option<String> {
    let ppid = parent_pid()?;
    match process_name(ppid) {
        Some(name) if !name.is_empty() => Some(format!("{name} (pid {ppid})")),
        _ => Some(format!("pid {ppid}")),
    }
}

/// The parent process id, from the kernel. `None` only if unobservable.
#[cfg(unix)]
fn parent_pid() -> Option<i32> {
    // SAFETY: `getppid` takes no arguments, has no preconditions, and cannot fail.
    let ppid = unsafe { libc::getppid() };
    if ppid > 0 { Some(ppid) } else { None }
}

/// Windows: the parent pid from a Toolhelp process snapshot.
#[cfg(windows)]
fn parent_pid() -> Option<i32> {
    win::find_process(std::process::id()).map(|f| f.parent_pid as i32)
}

#[cfg(not(any(unix, windows)))]
fn parent_pid() -> Option<i32> {
    None
}

/// Best-effort executable name/path for `pid`. Platform-specific; degrades to
/// `None` when it cannot be read (caller then shows just the pid).
#[cfg(target_os = "macos")]
fn process_name(pid: i32) -> Option<String> {
    // `proc_pidpath` fills an absolute executable path. We bind it directly
    // (libc does not expose the libproc shim) and keep the call minimal.
    const PROC_PIDPATHINFO_MAXSIZE: usize = 4096;
    unsafe extern "C" {
        fn proc_pidpath(
            pid: libc::c_int,
            buffer: *mut libc::c_void,
            buffersize: u32,
        ) -> libc::c_int;
    }
    let mut buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE];
    // SAFETY: `buf` is a valid, writable allocation of `buf.len()` bytes; the
    // call writes at most `buffersize` bytes and returns the count written.
    let n = unsafe {
        proc_pidpath(
            pid as libc::c_int,
            buf.as_mut_ptr() as *mut libc::c_void,
            buf.len() as u32,
        )
    };
    if n <= 0 {
        return None;
    }
    buf.truncate(n as usize);
    String::from_utf8(buf).ok().filter(|s| !s.is_empty())
}

/// Linux: read the executable name from `/proc/<pid>/comm` (the short name),
/// falling back to `/proc/<pid>/exe` (the resolved path) when available.
#[cfg(all(unix, not(target_os = "macos")))]
fn process_name(pid: i32) -> Option<String> {
    if let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) {
        if let Some(s) = exe.to_str() {
            if !s.is_empty() {
                return Some(s.to_string());
            }
        }
    }
    std::fs::read_to_string(format!("/proc/{pid}/comm"))
        .ok()
        .map(|s| s.trim_end().to_string())
        .filter(|s| !s.is_empty())
}

/// Windows: the image name (e.g. `node.exe`) from a Toolhelp process snapshot.
#[cfg(windows)]
fn process_name(pid: i32) -> Option<String> {
    win::find_process(pid as u32).and_then(|f| f.exe)
}

#[cfg(not(any(unix, windows)))]
fn process_name(_pid: i32) -> Option<String> {
    None
}

/// Windows process observation via a Toolhelp snapshot — the analog of the Unix
/// `getppid` plus `/proc/<pid>/comm`. One snapshot yields both the parent pid and
/// a process's image name; we never read arguments or environment, so no value
/// can leak (I7/I12). Degrades to `None` if the snapshot or entry is unavailable.
#[cfg(windows)]
mod win {
    use windows::Win32::Foundation::CloseHandle;
    use windows::Win32::System::Diagnostics::ToolHelp::{
        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
        TH32CS_SNAPPROCESS,
    };

    /// What we observe about a process: its parent pid and (best-effort) its image
    /// name.
    pub(super) struct Found {
        pub parent_pid: u32,
        pub exe: Option<String>,
    }

    /// Locate the snapshot entry for `pid`. `None` if not found / unobservable.
    pub(super) fn find_process(pid: u32) -> Option<Found> {
        // SAFETY: FFI. The snapshot handle is closed before returning; `entry` is a
        // valid out-param for each call; `dwSize` is set as the API requires.
        unsafe {
            let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
            let mut entry = PROCESSENTRY32W {
                dwSize: core::mem::size_of::<PROCESSENTRY32W>() as u32,
                ..Default::default()
            };
            let mut found = None;
            if Process32FirstW(snapshot, &mut entry).is_ok() {
                loop {
                    if entry.th32ProcessID == pid {
                        found = Some(Found {
                            parent_pid: entry.th32ParentProcessID,
                            exe: exe_name(&entry.szExeFile),
                        });
                        break;
                    }
                    if Process32NextW(snapshot, &mut entry).is_err() {
                        break;
                    }
                }
            }
            let _ = CloseHandle(snapshot);
            found
        }
    }

    /// The image name from a `szExeFile` field (UTF-16, NUL-terminated).
    fn exe_name(buf: &[u16; 260]) -> Option<String> {
        let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
        if len == 0 {
            return None;
        }
        String::from_utf16(&buf[..len])
            .ok()
            .filter(|s| !s.is_empty())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // The helper degrades gracefully: on the test host it observes a real parent
    // (the test harness / cargo), so it returns Some(_) and includes a pid. We do
    // not assert a specific name (that varies by host), only the shape.
    #[test]
    fn observe_parent_returns_non_empty_identity_with_pid() {
        let id = observe_parent().expect("a parent process should be observable on the test host");
        assert!(!id.is_empty());
        assert!(
            id.contains("pid "),
            "identity should always carry the observed pid, got {id:?}"
        );
    }

    // It must never leak more than a process identity (no secret value): the
    // returned string is just a name/path and a pid — assert it has no embedded
    // NUL and is a single line.
    #[test]
    fn observed_identity_is_a_clean_single_line() {
        let id = observe_parent().unwrap();
        assert!(!id.contains('\0'));
        assert!(!id.contains('\n'));
    }
}