Skip to main content

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}