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