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        // 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}