kovra_wrapper/caller.rs
1//! Observe the **requesting process** — the parent that launched this kovra
2//! process — to populate [`kovra_core::ConfirmRequest::requesting_process`] (I16,
3//! §8.3).
4//!
5//! This is a **trusted, observed fact**: the parent pid comes from the kernel
6//! (`getppid`), and the executable name is read from the OS by pid. It is never
7//! sourced from untrusted requester input, so it cannot be spoofed by the agent
8//! whose request triggered the prompt. The human approving at the Touch ID /
9//! file-broker prompt therefore sees *who* is really asking (e.g.
10//! `node (pid 1234)`) rather than always "kovra".
11//!
12//! Why this lives in the wrapper (not core): observing a process is OS work, and
13//! `core` must stay free of process-observation logic (CLAUDE.md rule 4). Both
14//! the CLI (`kovra show`, private-key ops) and the wrapper (`kovra run`) call
15//! [`observe_parent`]; the CLI depends on `kovra-wrapper`, so it reuses this
16//! helper rather than duplicating it.
17//!
18//! Degradation: if the name cannot be read, we fall back to `pid <N>`. We never
19//! include anything but a process identity (executable name/path + pid) — no
20//! arguments, no environment — so this can never leak a secret value (I7/I12).
21
22/// A human-readable identity for the **parent** process of the current process.
23///
24/// Returns e.g. `node (pid 1234)` or `/opt/homebrew/bin/node (pid 1234)`, or
25/// just `pid 1234` when the executable name cannot be resolved. Returns `None`
26/// only if even the parent pid cannot be observed (not expected on supported
27/// hosts, but it fails soft rather than fabricating an identity).
28#[must_use]
29pub fn observe_parent() -> Option<String> {
30 let ppid = parent_pid()?;
31 match process_name(ppid) {
32 Some(name) if !name.is_empty() => Some(format!("{name} (pid {ppid})")),
33 _ => Some(format!("pid {ppid}")),
34 }
35}
36
37/// The parent process id, from the kernel. `None` only if unobservable.
38#[cfg(unix)]
39fn parent_pid() -> Option<i32> {
40 // SAFETY: `getppid` takes no arguments, has no preconditions, and cannot fail.
41 let ppid = unsafe { libc::getppid() };
42 if ppid > 0 { Some(ppid) } else { None }
43}
44
45/// Windows: the parent pid from a Toolhelp process snapshot.
46#[cfg(windows)]
47fn parent_pid() -> Option<i32> {
48 win::find_process(std::process::id()).map(|f| f.parent_pid as i32)
49}
50
51#[cfg(not(any(unix, windows)))]
52fn parent_pid() -> Option<i32> {
53 None
54}
55
56/// Best-effort executable name/path for `pid`. Platform-specific; degrades to
57/// `None` when it cannot be read (caller then shows just the pid).
58#[cfg(target_os = "macos")]
59fn process_name(pid: i32) -> Option<String> {
60 // `proc_pidpath` fills an absolute executable path. We bind it directly
61 // (libc does not expose the libproc shim) and keep the call minimal.
62 const PROC_PIDPATHINFO_MAXSIZE: usize = 4096;
63 unsafe extern "C" {
64 fn proc_pidpath(
65 pid: libc::c_int,
66 buffer: *mut libc::c_void,
67 buffersize: u32,
68 ) -> libc::c_int;
69 }
70 let mut buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE];
71 // SAFETY: `buf` is a valid, writable allocation of `buf.len()` bytes; the
72 // call writes at most `buffersize` bytes and returns the count written.
73 let n = unsafe {
74 proc_pidpath(
75 pid as libc::c_int,
76 buf.as_mut_ptr() as *mut libc::c_void,
77 buf.len() as u32,
78 )
79 };
80 if n <= 0 {
81 return None;
82 }
83 buf.truncate(n as usize);
84 String::from_utf8(buf).ok().filter(|s| !s.is_empty())
85}
86
87/// Linux: read the executable name from `/proc/<pid>/comm` (the short name),
88/// falling back to `/proc/<pid>/exe` (the resolved path) when available.
89#[cfg(all(unix, not(target_os = "macos")))]
90fn process_name(pid: i32) -> Option<String> {
91 if let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) {
92 if let Some(s) = exe.to_str() {
93 if !s.is_empty() {
94 return Some(s.to_string());
95 }
96 }
97 }
98 std::fs::read_to_string(format!("/proc/{pid}/comm"))
99 .ok()
100 .map(|s| s.trim_end().to_string())
101 .filter(|s| !s.is_empty())
102}
103
104/// Windows: the image name (e.g. `node.exe`) from a Toolhelp process snapshot.
105#[cfg(windows)]
106fn process_name(pid: i32) -> Option<String> {
107 win::find_process(pid as u32).and_then(|f| f.exe)
108}
109
110#[cfg(not(any(unix, windows)))]
111fn process_name(_pid: i32) -> Option<String> {
112 None
113}
114
115/// Windows process observation via a Toolhelp snapshot — the analog of the Unix
116/// `getppid` plus `/proc/<pid>/comm`. One snapshot yields both the parent pid and
117/// a process's image name; we never read arguments or environment, so no value
118/// can leak (I7/I12). Degrades to `None` if the snapshot or entry is unavailable.
119#[cfg(windows)]
120mod win {
121 use windows::Win32::Foundation::CloseHandle;
122 use windows::Win32::System::Diagnostics::ToolHelp::{
123 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
124 TH32CS_SNAPPROCESS,
125 };
126
127 /// What we observe about a process: its parent pid and (best-effort) its image
128 /// name.
129 pub(super) struct Found {
130 pub parent_pid: u32,
131 pub exe: Option<String>,
132 }
133
134 /// Locate the snapshot entry for `pid`. `None` if not found / unobservable.
135 pub(super) fn find_process(pid: u32) -> Option<Found> {
136 // SAFETY: FFI. The snapshot handle is closed before returning; `entry` is a
137 // valid out-param for each call; `dwSize` is set as the API requires.
138 unsafe {
139 let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
140 let mut entry = PROCESSENTRY32W {
141 dwSize: core::mem::size_of::<PROCESSENTRY32W>() as u32,
142 ..Default::default()
143 };
144 let mut found = None;
145 if Process32FirstW(snapshot, &mut entry).is_ok() {
146 loop {
147 if entry.th32ProcessID == pid {
148 found = Some(Found {
149 parent_pid: entry.th32ParentProcessID,
150 exe: exe_name(&entry.szExeFile),
151 });
152 break;
153 }
154 if Process32NextW(snapshot, &mut entry).is_err() {
155 break;
156 }
157 }
158 }
159 let _ = CloseHandle(snapshot);
160 found
161 }
162 }
163
164 /// The image name from a `szExeFile` field (UTF-16, NUL-terminated).
165 fn exe_name(buf: &[u16; 260]) -> Option<String> {
166 let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
167 if len == 0 {
168 return None;
169 }
170 String::from_utf16(&buf[..len])
171 .ok()
172 .filter(|s| !s.is_empty())
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 // The helper degrades gracefully: on the test host it observes a real parent
181 // (the test harness / cargo), so it returns Some(_) and includes a pid. We do
182 // not assert a specific name (that varies by host), only the shape.
183 #[test]
184 fn observe_parent_returns_non_empty_identity_with_pid() {
185 let id = observe_parent().expect("a parent process should be observable on the test host");
186 assert!(!id.is_empty());
187 assert!(
188 id.contains("pid "),
189 "identity should always carry the observed pid, got {id:?}"
190 );
191 }
192
193 // It must never leak more than a process identity (no secret value): the
194 // returned string is just a name/path and a pid — assert it has no embedded
195 // NUL and is a single line.
196 #[test]
197 fn observed_identity_is_a_clean_single_line() {
198 let id = observe_parent().unwrap();
199 assert!(!id.contains('\0'));
200 assert!(!id.contains('\n'));
201 }
202}