kovra_native_macos/lib.rs
1//! `kovra-native-macos` — the macOS Touch ID [`Confirmer`] (spec §8, §14.1; L8
2//! `[host]`).
3//!
4//! This crate is the **native half** of the confirmation broker: it renders the
5//! core-authored [`ConfirmRequest`] in a macOS LocalAuthentication dialog and
6//! returns [`ConfirmOutcome::Approved`] / [`ConfirmOutcome::Denied`] /
7//! [`ConfirmOutcome::TimedOut`]. It is a *third* [`Confirmer`] implementation
8//! beside [`kovra_core::CliApproveConfirmer`] and [`kovra_core::FileConfirmer`].
9//!
10//! Design constraints (immutable — see `CLAUDE.md`, spec §2):
11//!
12//! - **I16 — the prompt is authoritative from the core.** The native dialog
13//! *only* renders what the core put in [`ConfirmRequest`] (resolved `argv`,
14//! coordinate, sensitivity, environment, origin). It never fabricates its own
15//! prompt, and any requester-supplied free text is shown clearly segregated as
16//! untrusted. See [`render::prompt_text`].
17//! - **No self-approve (§8.2).** Approval is performed by a human at the Touch ID
18//! sensor — a channel outside the model's process. The agent only *triggers*
19//! the prompt; it cannot satisfy it.
20//! - **Timeout ⇒ deny (§8).** Anything that is not an explicit biometric success
21//! is a denial. A timeout is reported distinctly for audit but never delivers.
22//! - **No secret value is ever rendered, logged, or returned (I7/I12).** Only the
23//! coordinate *address* and the resolved command appear in the dialog.
24//!
25//! ## `core` does not depend on this crate
26//!
27//! Trait injection points *into* core: `native-macos` depends on `kovra-core`,
28//! never the reverse (spec §17). The CLI selects a [`Confirmer`] at the edge.
29//!
30//! ## Cross-platform
31//!
32//! The real LocalAuthentication binding lives under `cfg(target_os = "macos")`.
33//! On every other target the crate compiles to a no-op stub whose
34//! [`Biometric::prompt`] reports "unavailable" (denies) and whose
35//! [`biometrics_available`] returns `false`, so the CLI auto-falls-back to the
36//! file broker and the whole workspace builds on Linux CI.
37//!
38//! ## `[host]` validation
39//!
40//! The real Touch ID path (`LAContext`) is **not** exercised by automated tests —
41//! it requires real hardware and a real human finger. It is validated by a human
42//! on an M4 (see the crate's README / KOV-15 checklist). Automated tests here use
43//! a deterministic mock [`Biometric`] and assert the OS-independent contract
44//! (rendering, timeout⇒deny, no-self-approve, no leak).
45
46use std::time::Duration;
47
48use kovra_core::{Biometric, ConfirmOutcome, ConfirmRequest, Confirmer};
49
50pub mod formatter;
51pub mod render;
52
53pub use formatter::DiskutilFormatter;
54
55#[cfg(target_os = "macos")]
56mod macos;
57
58/// Whether an attended biometric prompt can actually be shown on this host
59/// right now: macOS with biometrics present and enrolled. On non-macOS, or when
60/// no hardware is present / the user is not enrolled, this is `false` and the
61/// caller should fall back to [`kovra_core::FileConfirmer`].
62///
63/// This is a cheap, side-effect-free capability probe (`LAContext
64/// canEvaluatePolicy:`); it does **not** show a dialog.
65#[must_use]
66pub fn biometrics_available() -> bool {
67 #[cfg(target_os = "macos")]
68 {
69 macos::can_evaluate()
70 }
71 #[cfg(not(target_os = "macos"))]
72 {
73 false
74 }
75}
76
77/// The native [`Biometric`] for this host.
78///
79/// On macOS this is the real `LAContext`-backed prompt (`[host]`). On other
80/// targets it is a stub that always denies (biometrics is unavailable), which
81/// keeps the type usable in cross-platform builds even though the CLI will never
82/// select it off-macOS.
83pub struct NativeBiometric {
84 #[cfg(target_os = "macos")]
85 inner: macos::MacBiometric,
86}
87
88impl NativeBiometric {
89 /// Construct the host biometric handle.
90 #[must_use]
91 pub fn new() -> Self {
92 Self {
93 #[cfg(target_os = "macos")]
94 inner: macos::MacBiometric::new(),
95 }
96 }
97}
98
99impl Default for NativeBiometric {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl Biometric for NativeBiometric {
106 fn prompt(&self, req: &ConfirmRequest, timeout: Duration) -> ConfirmOutcome {
107 #[cfg(target_os = "macos")]
108 {
109 self.inner.prompt(req, timeout)
110 }
111 #[cfg(not(target_os = "macos"))]
112 {
113 // No biometrics off-macOS: fail safe to denial. The CLI never selects
114 // this path (it falls back to the file broker), but the trait must be
115 // total.
116 let _ = (req, timeout);
117 ConfirmOutcome::Denied
118 }
119 }
120}
121
122/// A [`Confirmer`] that resolves a request through an attended biometric prompt.
123///
124/// This is the adapter from the OS-independent [`Confirmer`] surface (what the
125/// wrapper/CLI consume) onto a [`Biometric`] implementation (the native dialog).
126/// The biometric does the work; this type just maps the trait. The `B` generic
127/// lets tests inject a deterministic mock [`Biometric`] without touching hardware.
128pub struct BiometricConfirmer<B: Biometric = NativeBiometric> {
129 biometric: B,
130}
131
132impl BiometricConfirmer<NativeBiometric> {
133 /// A confirmer backed by the host's native biometric prompt.
134 #[must_use]
135 pub fn new() -> Self {
136 Self {
137 biometric: NativeBiometric::new(),
138 }
139 }
140}
141
142impl Default for BiometricConfirmer<NativeBiometric> {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl<B: Biometric> BiometricConfirmer<B> {
149 /// A confirmer backed by an explicit [`Biometric`] (tests inject a mock).
150 pub fn with_biometric(biometric: B) -> Self {
151 Self { biometric }
152 }
153}
154
155impl<B: Biometric> Confirmer for BiometricConfirmer<B> {
156 fn confirm(&self, req: &ConfirmRequest, timeout: Duration) -> ConfirmOutcome {
157 // The biometric prompt is the *only* way this resolves. There is no
158 // in-process approve path (§8.2): the human authorizes at the sensor.
159 self.biometric.prompt(req, timeout)
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use kovra_core::{Origin, Sensitivity};
167 use std::cell::Cell;
168
169 fn req() -> ConfirmRequest {
170 ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Agent)
171 .with_command("/usr/bin/deploy --env prod")
172 }
173
174 /// A deterministic stand-in for the native dialog. Records that it was asked
175 /// and returns a preset outcome — no hardware, no Touch ID.
176 struct MockBiometric {
177 outcome: ConfirmOutcome,
178 prompted: Cell<u32>,
179 last_text: std::cell::RefCell<Option<String>>,
180 }
181
182 impl MockBiometric {
183 fn new(outcome: ConfirmOutcome) -> Self {
184 Self {
185 outcome,
186 prompted: Cell::new(0),
187 last_text: std::cell::RefCell::new(None),
188 }
189 }
190 }
191
192 impl Biometric for MockBiometric {
193 fn prompt(&self, req: &ConfirmRequest, _timeout: Duration) -> ConfirmOutcome {
194 self.prompted.set(self.prompted.get() + 1);
195 // Mirror the real impl: render the authoritative text from the core
196 // request (so a leak in rendering would be caught here too).
197 *self.last_text.borrow_mut() = Some(render::prompt_text(req));
198 self.outcome
199 }
200 }
201
202 // I3: a high/prod confirmation drives the confirmer and the outcome decides
203 // delivery — Approved delivers, Denied/TimedOut never do.
204 #[test]
205 fn i3_high_prod_drives_confirm_and_outcome_gates_delivery() {
206 for outcome in [
207 ConfirmOutcome::Approved,
208 ConfirmOutcome::Denied,
209 ConfirmOutcome::TimedOut,
210 ] {
211 let bio = MockBiometric::new(outcome);
212 let confirmer = BiometricConfirmer::with_biometric(bio);
213 let got = confirmer.confirm(&req(), Duration::from_secs(1));
214 assert_eq!(got, outcome);
215 assert_eq!(got.is_approved(), outcome == ConfirmOutcome::Approved);
216 }
217 }
218
219 // §8: timeout fails safe to denial (is_approved() is false).
220 #[test]
221 fn timeout_fails_safe_to_denial() {
222 let confirmer =
223 BiometricConfirmer::with_biometric(MockBiometric::new(ConfirmOutcome::TimedOut));
224 let got = confirmer.confirm(&req(), Duration::ZERO);
225 assert_eq!(got, ConfirmOutcome::TimedOut);
226 assert!(!got.is_approved());
227 }
228
229 // §8.2: the confirmer has no in-process approve method — resolution comes only
230 // from the biometric (the human at the sensor). Confirming a request always
231 // routes through the biometric prompt; there is no path that approves without
232 // it. We assert structurally: every confirm() invokes the biometric exactly
233 // once and returns precisely what it decided (no override, no self-approve).
234 #[test]
235 fn no_self_approve_resolution_only_via_biometric() {
236 let bio = MockBiometric::new(ConfirmOutcome::Denied);
237 let confirmer = BiometricConfirmer::with_biometric(bio);
238 let got = confirmer.confirm(&req(), Duration::from_secs(1));
239 // The only resolution is the biometric's denial — the confirmer cannot
240 // turn it into an approval.
241 assert_eq!(got, ConfirmOutcome::Denied);
242 assert_eq!(confirmer.biometric.prompted.get(), 1);
243 }
244
245 // I7/I12: the value never reaches the confirm path. We attach a realistic
246 // (fake) secret-looking string only as the *coordinate address* would never
247 // contain it; assert the rendered dialog the biometric sees carries no value,
248 // only the address + command.
249 #[test]
250 fn i7_i12_no_secret_value_in_confirm_path() {
251 let bio = MockBiometric::new(ConfirmOutcome::Approved);
252 let confirmer = BiometricConfirmer::with_biometric(bio);
253 let _ = confirmer.confirm(&req(), Duration::from_secs(1));
254 let text = confirmer.biometric.last_text.borrow().clone().unwrap();
255 // The dialog contains the address (environment + secret, env prefix
256 // stripped) and the command, never a value (there is no value field on
257 // ConfirmRequest to begin with — this guards rendering).
258 assert!(text.contains("Environment: prod"));
259 assert!(text.contains("Secret: db/password"));
260 assert!(text.contains("/usr/bin/deploy --env prod"));
261 // No accidental value-shaped leakage.
262 assert!(!text.to_lowercase().contains("secret-value"));
263 }
264}