kovra_core/policy.rs
1//! The sensitivity/scope decision — the single funnel every face calls
2//! (spec §3, invariants I2/I3/I5/I11/I13/I14).
3//!
4//! [`decide`] takes an [`AccessRequest`] and an [`AgentScope`] and returns a
5//! [`Decision`]. Policy lives **here**, in the core; the CLI, Wrapper, Web UI,
6//! and MCP server consume the decision and never re-derive it (spec §2, §15).
7//!
8//! Order of evaluation:
9//! 1. **Scope first (I13).** A coordinate or operation outside scope is
10//! [`Decision::Unaddressable`] — it does not exist for the channel, it is not
11//! "denied after the fact".
12//! 2. **Sensitivity × environment × surface × origin** — the §3.1 table plus
13//! I2 (`inject-only` never revealed), I11 (MCP never reveals critical), I14
14//! (`prod` plaintext into context only by a human-initiated reveal).
15//! 3. **`high` ⇒ confirmation (I3).**
16
17use crate::coordinate::{Coordinate, EnvSegment};
18use crate::scope::{AgentScope, Operation, Origin, Surface};
19use crate::sensitivity::Sensitivity;
20
21/// The canonical `prod` environment name.
22pub const PROD: &str = "prod";
23
24/// The outcome of a policy decision.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Decision {
27 /// Permitted to proceed with no interactive confirmation.
28 Allow,
29 /// Permitted only after attended confirmation (biometric / `kovra approve`).
30 RequireConfirmation,
31 /// Forbidden; carries the reason (for audit, never a value).
32 Deny(DenyReason),
33 /// Not addressable in this scope (I13) — distinct from `Deny`.
34 Unaddressable,
35}
36
37/// Why a request was denied. Carries no secret material (I12).
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum DenyReason {
40 /// `inject-only` may never be revealed (I2).
41 InjectOnlyNeverRevealed,
42 /// MCP may not reveal `high`/`prod`/`inject-only` plaintext (I11).
43 McpCriticalForbidden,
44 /// The Web UI may not render `high`/`inject-only` plaintext (I1).
45 WebUiCriticalMasked,
46 /// An agent may not pull `prod` plaintext into context (I14).
47 ProdRevealIntoAgentContext,
48 /// The secret is not marked revealable.
49 NotRevealable,
50}
51
52/// A request to act on a secret. The environment is read from the coordinate.
53#[derive(Debug, Clone)]
54pub struct AccessRequest<'a> {
55 /// The (resolved) coordinate being acted on.
56 pub coordinate: &'a Coordinate,
57 /// The owning project, or `None` for the global vault.
58 pub project: Option<&'a str>,
59 /// The secret's sensitivity.
60 pub sensitivity: Sensitivity,
61 /// Whether the secret is opted into reveal (the §3.1 "revealable" flag).
62 ///
63 /// This MUST be sourced from the **stored secret**, never from caller
64 /// intent — otherwise a face could fabricate `revealable: true` and defeat
65 /// I11. It is persisted on [`crate::SecretRecord`] (L9) and read back via
66 /// [`crate::SecretRecord::revealable`], like `sensitivity`. The CLI reveal
67 /// path leaves it `false` (it never consults the MCP-only opt-in); the FFI
68 /// reveal path populates it from the record.
69 pub revealable: bool,
70 /// What the caller wants to do.
71 pub operation: Operation,
72 /// Which face is asking.
73 pub surface: Surface,
74 /// Who initiated it.
75 pub origin: Origin,
76}
77
78impl AccessRequest<'_> {
79 /// Whether the coordinate's environment is `prod` (literal). A `${ENV}`
80 /// placeholder is never `prod` here — it is unaddressable until resolved.
81 fn is_prod(&self) -> bool {
82 matches!(&self.coordinate.environment, EnvSegment::Literal(e) if e == PROD)
83 }
84}
85
86/// The policy funnel (spec §3, I2/I3/I11/I13/I14).
87pub fn decide(req: &AccessRequest, scope: &AgentScope) -> Decision {
88 // 1. Scope first (I13): unaddressable coordinate or ungranted operation.
89 if !scope.addresses(req.coordinate, req.project) || !scope.permits(req.operation) {
90 return Decision::Unaddressable;
91 }
92
93 let prod = req.is_prod();
94 let high = req.sensitivity == Sensitivity::High;
95 let inject_only = req.sensitivity == Sensitivity::InjectOnly;
96
97 match req.operation {
98 // Metadata never exposes a value — addressable ⇒ allowed.
99 Operation::Metadata => Decision::Allow,
100
101 // Injection moves the value *through* an operation; it never enters the
102 // caller's context. `high`/`prod` injection requires confirmation
103 // (I3/I15); other levels (incl. inject-only, its only delivery) proceed.
104 Operation::Inject => {
105 // The biometric confirmation gate is sensitivity-only (I3 — orthogonal
106 // to environment). The executor-allowlist gate (I15, high/prod) is a
107 // separate containment enforced by the Wrapper, not a `decide` outcome.
108 if inject_requires_confirmation(req.sensitivity) {
109 Decision::RequireConfirmation
110 } else {
111 Decision::Allow
112 }
113 }
114
115 // Reveal returns plaintext *into* the caller's context — the guarded path.
116 Operation::Reveal => {
117 if inject_only {
118 return Decision::Deny(DenyReason::InjectOnlyNeverRevealed);
119 }
120 match req.surface {
121 // I11: MCP never reveals high/prod/inject-only; otherwise only
122 // a revealable, non-prod, non-high secret.
123 Surface::Mcp => {
124 if prod || high {
125 Decision::Deny(DenyReason::McpCriticalForbidden)
126 } else if !req.revealable {
127 Decision::Deny(DenyReason::NotRevealable)
128 } else {
129 Decision::Allow
130 }
131 }
132 // I1: the Web UI never renders high/inject-only plaintext (masked
133 // + fingerprint); low/medium reveal on explicit click.
134 Surface::WebUi => {
135 if high {
136 Decision::Deny(DenyReason::WebUiCriticalMasked)
137 } else {
138 Decision::Allow
139 }
140 }
141 // CLI is the only path that can reveal critical plaintext, and
142 // only deliberately: prod into an agent's context is forbidden
143 // (I14); a human prod reveal is the biometric point-reveal door;
144 // any high reveal requires confirmation (I3).
145 Surface::Cli => {
146 if prod {
147 match req.origin {
148 Origin::Agent => Decision::Deny(DenyReason::ProdRevealIntoAgentContext),
149 Origin::Human => Decision::RequireConfirmation,
150 }
151 } else if high {
152 Decision::RequireConfirmation
153 } else {
154 Decision::Allow
155 }
156 }
157 }
158 }
159 }
160}
161
162/// The sensitivity a newly created secret is born with (I5): `prod` ⇒ `high`,
163/// otherwise the caller's chosen default. Lowering it later is a deliberate,
164/// audited act (see the audit module's `SensitivityDowngrade`).
165pub fn birth_sensitivity(environment: &str, non_prod_default: Sensitivity) -> Sensitivity {
166 if environment == PROD {
167 Sensitivity::High
168 } else {
169 non_prod_default
170 }
171}
172
173/// I5 — whether changing sensitivity `from` → `to` is a **downgrade** (a
174/// deliberate, audited act; see the audit module's `SensitivityDowngrade`).
175/// Ordered by interactive-reveal strictness: `low < medium < high`, with
176/// `inject-only` the strictest (never revealed). The classification lives here,
177/// not in any face, so every interface records the same I5 trigger.
178pub fn is_downgrade(from: Sensitivity, to: Sensitivity) -> bool {
179 fn rank(s: Sensitivity) -> u8 {
180 match s {
181 Sensitivity::Low => 0,
182 Sensitivity::Medium => 1,
183 Sensitivity::High => 2,
184 Sensitivity::InjectOnly => 3,
185 }
186 }
187 rank(to) < rank(from)
188}
189
190/// I5 + I16 — whether lowering sensitivity `from` → `to` requires an **attended
191/// confirmation** (Touch ID / `kovra approve`) *before* it is applied, on top of
192/// the audit trail. A downgrade *from* a **critical** level (`high` or
193/// `inject-only`) removes protection the secret had — it could become revealable
194/// where it was not — so it is gated like any other critical delivery (I3/I16).
195/// A downgrade from a non-critical level (e.g. `medium` → `low`) is audited but
196/// not gated. `true` only when `from → to` is a downgrade AND `from` is `high`
197/// or `inject-only`. Single-sourced here so every face gates it the same way.
198pub fn downgrade_requires_confirmation(from: Sensitivity, to: Sensitivity) -> bool {
199 is_downgrade(from, to) && matches!(from, Sensitivity::High | Sensitivity::InjectOnly)
200}
201
202/// A **destructive** action (delete) requires an attended broker confirmation
203/// (Touch ID / `kovra approve`) only for the **critical** tier — `high` /
204/// `inject-only` — i.e. exactly the secrets that already require attended
205/// delivery to even *view* (I1/I2/I3). Non-critical (`low`/`medium`) secrets are
206/// viewable on demand without biometrics, so their deletion is guarded by
207/// lighter, surface-local friction (e.g. the Web UI's type-the-name modal)
208/// rather than the broker. Single-sourced here so every face gates it the same.
209pub fn delete_requires_confirmation(sensitivity: Sensitivity) -> bool {
210 matches!(sensitivity, Sensitivity::High | Sensitivity::InjectOnly)
211}
212
213/// I4a — a `prod` secret may not be packaged into an artifact (§7). Enforced at
214/// the package layer (L7); exposed here so the policy meaning is single-sourced.
215pub fn prod_not_packageable(environment: &str) -> bool {
216 environment == PROD
217}
218
219/// I4b — a `prod` secret may not be consumed via an unattended token (§7.2).
220/// Enforced at L7.
221pub fn prod_blocks_unattended(environment: &str) -> bool {
222 environment == PROD
223}
224
225/// I4c — a `prod` coordinate may not use a `| default` fallback in resolution.
226/// Enforced by the resolver (L4).
227pub fn prod_forbids_fallback(environment: &str) -> bool {
228 environment == PROD
229}
230
231/// I3 — injecting this value requires an **attended biometric confirmation**:
232/// `true` iff the secret is `high`. **Orthogonal to environment** (I3): a
233/// deliberately-downgraded `prod` secret (e.g. `low`) injects without a prompt,
234/// so a downgrade is *effective* for friction — `prod` defaults to `high` at
235/// birth (I5), which is what gates it by default, not the environment itself.
236///
237/// This is **distinct** from the executor-allowlist gate
238/// ([`inject_requires_allowlist`], I15) — the allowlist remains environment-aware
239/// (`high`/`prod`), but the allowlist is a config check, not a per-command prompt
240/// (KOV-25 decision, §21). Single source of the confirmation trigger; the
241/// `Operation::Inject` branch of [`decide`] and the Wrapper (L5) both consume it.
242pub fn inject_requires_confirmation(sensitivity: Sensitivity) -> bool {
243 sensitivity == Sensitivity::High
244}
245
246/// I15 — injecting this value requires the target command to be on the **executor
247/// allowlist** of reviewed executables: `true` for `high` sensitivity or a `prod`
248/// environment. This containment is environment-aware (a `prod` injection must
249/// target a reviewed executable even when the secret was downgraded below
250/// `high`), and is enforced by the Wrapper (L5) *before* launch. It is a config
251/// gate, **not** a biometric prompt — the prompt is governed separately by
252/// [`inject_requires_confirmation`] (I3, sensitivity-only). See the KOV-25
253/// decision (§21) on why the two gates are split.
254pub fn inject_requires_allowlist(sensitivity: Sensitivity, is_prod: bool) -> bool {
255 sensitivity == Sensitivity::High || is_prod
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use std::str::FromStr;
262
263 fn coord(s: &str) -> Coordinate {
264 Coordinate::from_str(s).unwrap()
265 }
266
267 fn req<'a>(
268 c: &'a Coordinate,
269 sensitivity: Sensitivity,
270 operation: Operation,
271 surface: Surface,
272 origin: Origin,
273 ) -> AccessRequest<'a> {
274 AccessRequest {
275 coordinate: c,
276 project: None,
277 sensitivity,
278 revealable: false,
279 operation,
280 surface,
281 origin,
282 }
283 }
284
285 #[test]
286 fn metadata_is_allowed_when_addressable() {
287 let c = coord("secret:prod/db/password");
288 let d = decide(
289 &req(
290 &c,
291 Sensitivity::High,
292 Operation::Metadata,
293 Surface::Mcp,
294 Origin::Agent,
295 ),
296 &AgentScope::full(),
297 );
298 assert_eq!(d, Decision::Allow);
299 }
300
301 #[test]
302 fn out_of_scope_is_unaddressable_not_denied() {
303 let c = coord("secret:prod/db/password");
304 let scope = AgentScope::metadata_only(); // reveal not permitted
305 let d = decide(
306 &req(
307 &c,
308 Sensitivity::Low,
309 Operation::Reveal,
310 Surface::Cli,
311 Origin::Human,
312 ),
313 &scope,
314 );
315 assert_eq!(d, Decision::Unaddressable);
316 }
317
318 #[test]
319 fn inject_only_is_never_revealed_on_any_surface() {
320 let c = coord("secret:dev/app/key");
321 for surface in [Surface::Cli, Surface::WebUi, Surface::Mcp] {
322 let d = decide(
323 &req(
324 &c,
325 Sensitivity::InjectOnly,
326 Operation::Reveal,
327 surface,
328 Origin::Human,
329 ),
330 &AgentScope::full(),
331 );
332 assert_eq!(d, Decision::Deny(DenyReason::InjectOnlyNeverRevealed));
333 }
334 }
335
336 #[test]
337 fn high_inject_requires_confirmation_low_does_not() {
338 let c = coord("secret:dev/app/key");
339 assert_eq!(
340 decide(
341 &req(
342 &c,
343 Sensitivity::High,
344 Operation::Inject,
345 Surface::Cli,
346 Origin::Human
347 ),
348 &AgentScope::full()
349 ),
350 Decision::RequireConfirmation
351 );
352 assert_eq!(
353 decide(
354 &req(
355 &c,
356 Sensitivity::Low,
357 Operation::Inject,
358 Surface::Cli,
359 Origin::Human
360 ),
361 &AgentScope::full()
362 ),
363 Decision::Allow
364 );
365 }
366
367 #[test]
368 fn mcp_never_reveals_critical() {
369 let prod = coord("secret:prod/db/password");
370 let dev = coord("secret:dev/app/key");
371 // prod → deny
372 assert_eq!(
373 decide(
374 &req(
375 &prod,
376 Sensitivity::Medium,
377 Operation::Reveal,
378 Surface::Mcp,
379 Origin::Agent
380 ),
381 &AgentScope::full()
382 ),
383 Decision::Deny(DenyReason::McpCriticalForbidden)
384 );
385 // high → deny
386 assert_eq!(
387 decide(
388 &req(
389 &dev,
390 Sensitivity::High,
391 Operation::Reveal,
392 Surface::Mcp,
393 Origin::Agent
394 ),
395 &AgentScope::full()
396 ),
397 Decision::Deny(DenyReason::McpCriticalForbidden)
398 );
399 // non-prod medium but not revealable → deny
400 assert_eq!(
401 decide(
402 &req(
403 &dev,
404 Sensitivity::Medium,
405 Operation::Reveal,
406 Surface::Mcp,
407 Origin::Agent
408 ),
409 &AgentScope::full()
410 ),
411 Decision::Deny(DenyReason::NotRevealable)
412 );
413 // non-prod medium revealable → allow
414 let mut r = req(
415 &dev,
416 Sensitivity::Medium,
417 Operation::Reveal,
418 Surface::Mcp,
419 Origin::Agent,
420 );
421 r.revealable = true;
422 assert_eq!(decide(&r, &AgentScope::full()), Decision::Allow);
423 }
424
425 #[test]
426 fn prod_reveal_into_agent_context_is_denied_human_requires_confirmation() {
427 let c = coord("secret:prod/db/password");
428 // I14: agent pulling prod into context → deny (even if downgraded to medium)
429 assert_eq!(
430 decide(
431 &req(
432 &c,
433 Sensitivity::Medium,
434 Operation::Reveal,
435 Surface::Cli,
436 Origin::Agent
437 ),
438 &AgentScope::full()
439 ),
440 Decision::Deny(DenyReason::ProdRevealIntoAgentContext)
441 );
442 // human point reveal → confirmation (the deliberate door)
443 assert_eq!(
444 decide(
445 &req(
446 &c,
447 Sensitivity::High,
448 Operation::Reveal,
449 Surface::Cli,
450 Origin::Human
451 ),
452 &AgentScope::full()
453 ),
454 Decision::RequireConfirmation
455 );
456 }
457
458 #[test]
459 fn webui_masks_high_reveals_low_medium() {
460 let c = coord("secret:dev/app/key");
461 assert_eq!(
462 decide(
463 &req(
464 &c,
465 Sensitivity::High,
466 Operation::Reveal,
467 Surface::WebUi,
468 Origin::Human
469 ),
470 &AgentScope::full()
471 ),
472 Decision::Deny(DenyReason::WebUiCriticalMasked)
473 );
474 assert_eq!(
475 decide(
476 &req(
477 &c,
478 Sensitivity::Medium,
479 Operation::Reveal,
480 Surface::WebUi,
481 Origin::Human
482 ),
483 &AgentScope::full()
484 ),
485 Decision::Allow
486 );
487 }
488
489 #[test]
490 fn cli_high_reveal_requires_confirmation() {
491 let c = coord("secret:dev/app/key");
492 assert_eq!(
493 decide(
494 &req(
495 &c,
496 Sensitivity::High,
497 Operation::Reveal,
498 Surface::Cli,
499 Origin::Human
500 ),
501 &AgentScope::full()
502 ),
503 Decision::RequireConfirmation
504 );
505 }
506
507 #[test]
508 fn birth_sensitivity_prod_is_high() {
509 assert_eq!(birth_sensitivity(PROD, Sensitivity::Low), Sensitivity::High);
510 assert_eq!(birth_sensitivity("dev", Sensitivity::Low), Sensitivity::Low);
511 assert_eq!(
512 birth_sensitivity("staging", Sensitivity::Medium),
513 Sensitivity::Medium
514 );
515 }
516
517 #[test]
518 fn prod_structural_predicates() {
519 assert!(prod_not_packageable(PROD));
520 assert!(prod_blocks_unattended(PROD));
521 assert!(prod_forbids_fallback(PROD));
522 assert!(!prod_forbids_fallback("dev"));
523 }
524
525 #[test]
526 fn downgrade_from_critical_requires_confirmation() {
527 // From high: any downgrade is gated.
528 assert!(downgrade_requires_confirmation(
529 Sensitivity::High,
530 Sensitivity::Medium
531 ));
532 assert!(downgrade_requires_confirmation(
533 Sensitivity::High,
534 Sensitivity::Low
535 ));
536 // From inject-only (the strictest): loosening to a revealable level is gated.
537 assert!(downgrade_requires_confirmation(
538 Sensitivity::InjectOnly,
539 Sensitivity::High
540 ));
541 assert!(downgrade_requires_confirmation(
542 Sensitivity::InjectOnly,
543 Sensitivity::Low
544 ));
545 // From a non-critical level: audited, but not gated.
546 assert!(!downgrade_requires_confirmation(
547 Sensitivity::Medium,
548 Sensitivity::Low
549 ));
550 // Not a downgrade → never gated (raising, or no change).
551 assert!(!downgrade_requires_confirmation(
552 Sensitivity::Low,
553 Sensitivity::High
554 ));
555 assert!(!downgrade_requires_confirmation(
556 Sensitivity::High,
557 Sensitivity::High
558 ));
559 }
560
561 // KOV-30 — delete confirmation mirrors the reveal tier: the broker gates only
562 // the critical levels (high / inject-only); low / medium are not broker-gated
563 // (the Web UI guards them with a type-the-name modal instead).
564 #[test]
565 fn delete_requires_confirmation_for_critical_only() {
566 assert!(delete_requires_confirmation(Sensitivity::High));
567 assert!(delete_requires_confirmation(Sensitivity::InjectOnly));
568 assert!(!delete_requires_confirmation(Sensitivity::Medium));
569 assert!(!delete_requires_confirmation(Sensitivity::Low));
570 }
571
572 #[test]
573 fn downgrade_detection_follows_reveal_strictness() {
574 assert!(is_downgrade(Sensitivity::High, Sensitivity::Medium));
575 assert!(is_downgrade(Sensitivity::High, Sensitivity::Low));
576 assert!(is_downgrade(Sensitivity::Medium, Sensitivity::Low));
577 assert!(!is_downgrade(Sensitivity::Low, Sensitivity::High));
578 assert!(!is_downgrade(Sensitivity::High, Sensitivity::High));
579 // inject-only is the strictest: tightening to it is not a downgrade;
580 // loosening from it to a revealable level is.
581 assert!(!is_downgrade(Sensitivity::High, Sensitivity::InjectOnly));
582 assert!(is_downgrade(Sensitivity::InjectOnly, Sensitivity::High));
583 }
584
585 // I3 (KOV-25): the biometric confirmation gate is SENSITIVITY-ONLY — `high`
586 // is gated, everything else is not, regardless of environment. A deliberately
587 // downgraded `prod` secret therefore injects without a prompt (the downgrade
588 // is effective); `prod` is gated by default only because it is born `high`.
589 #[test]
590 fn inject_confirmation_is_sensitivity_only() {
591 assert!(inject_requires_confirmation(Sensitivity::High));
592 assert!(!inject_requires_confirmation(Sensitivity::Medium));
593 assert!(!inject_requires_confirmation(Sensitivity::Low));
594 // inject-only's normal delivery is injection — not a `high` reveal — so it
595 // is not confirmation-gated.
596 assert!(!inject_requires_confirmation(Sensitivity::InjectOnly));
597 }
598
599 // I15: the executor-allowlist gate stays environment-aware — `high` OR `prod`
600 // injection must target a reviewed executable, even a downgraded prod secret.
601 #[test]
602 fn inject_allowlist_is_high_or_prod() {
603 assert!(inject_requires_allowlist(Sensitivity::High, false));
604 assert!(inject_requires_allowlist(Sensitivity::Medium, true));
605 assert!(inject_requires_allowlist(Sensitivity::Low, true)); // downgraded prod
606 assert!(inject_requires_allowlist(Sensitivity::InjectOnly, true));
607 // non-prod, non-high → no allowlist requirement (throwaway dev/test, §5.1).
608 assert!(!inject_requires_allowlist(Sensitivity::Low, false));
609 assert!(!inject_requires_allowlist(Sensitivity::Medium, false));
610 }
611
612 // KOV-25 end-to-end at the funnel: a downgraded `prod` secret injects WITHOUT
613 // confirmation (the prompt is sensitivity-only, I3).
614 #[test]
615 fn downgraded_prod_inject_is_allowed_without_confirmation() {
616 let c = coord("secret:prod/db/password");
617 // prod + low → Inject → Allow (no biometric prompt).
618 assert_eq!(
619 decide(
620 &req(
621 &c,
622 Sensitivity::Low,
623 Operation::Inject,
624 Surface::Cli,
625 Origin::Human
626 ),
627 &AgentScope::full()
628 ),
629 Decision::Allow
630 );
631 // prod + high (the birth default) → still gated.
632 assert_eq!(
633 decide(
634 &req(
635 &c,
636 Sensitivity::High,
637 Operation::Inject,
638 Surface::Cli,
639 Origin::Human
640 ),
641 &AgentScope::full()
642 ),
643 Decision::RequireConfirmation
644 );
645 }
646}