Skip to main content

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}