Skip to main content

vellaveto_engine/
verified_core.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Verified core verdict computation.
9//!
10//! This module contains the pure verdict computation logic that is verified
11//! by Verus (deductive, all inputs) and tested by Kani (bounded model checking).
12//!
13//! The key abstraction is [`ResolvedMatch`]: the unverified wrapper code resolves
14//! whether each policy matches the action (String operations, glob matching,
15//! Unicode normalization, HashMap lookups) and produces a `Vec<ResolvedMatch>`.
16//! This module computes the verdict from that Vec using pure logic — no String,
17//! no HashMap, no serde, no glob.
18//!
19//! # Verification Properties (V1-V8)
20//!
21//! | ID | Property | Meaning |
22//! |----|----------|---------|
23//! | V1 | Fail-closed empty | Empty input → Deny |
24//! | V2 | Fail-closed no match | All `!matched` → Deny |
25//! | V3 | Allow requires match | Allow → ∃ matching Allow policy with no override |
26//! | V4 | Rule override forces Deny | Path/network/IP override on first match → Deny |
27//! | V5 | Totality | Function always terminates |
28//! | V6 | Priority ordering | Higher-priority match wins (requires sorted input) |
29//! | V7 | Deny-dominance at equal priority | Deny beats Allow at same priority (sorted) |
30//! | V8 | Conditional pass-through | Unfired condition → evaluation continues |
31//!
32//! # Trust Boundary
33//!
34//! The wrapper (unverified) builds `Vec<ResolvedMatch>` from the action and policies.
35//! The core (verified) computes the verdict from that Vec. The trust boundary is:
36//! "the wrapper correctly resolves matches; the core correctly computes verdicts."
37//!
38//! See `docs/TRUSTED_COMPUTING_BASE.md` for the full trust model.
39//! See `formal/verus/verified_core.rs` for the Verus-annotated version with specs.
40
41/// The result of the core verdict computation.
42///
43/// This enum mirrors `Verdict` but without String payloads — the verified core
44/// determines the verdict *kind*, and the caller attaches the reason string.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum VerdictKind {
47    /// Action is allowed.
48    Allow,
49    /// Action is denied.
50    Deny,
51    /// Action requires human approval.
52    RequireApproval,
53}
54
55impl VerdictKind {
56    /// Returns true if this verdict is Deny.
57    #[inline]
58    pub fn is_deny(self) -> bool {
59        matches!(self, VerdictKind::Deny)
60    }
61
62    /// Returns true if this verdict is Allow.
63    #[inline]
64    pub fn is_allow(self) -> bool {
65        matches!(self, VerdictKind::Allow)
66    }
67}
68
69/// A pre-resolved policy match with all verdict-relevant information.
70///
71/// The unverified wrapper produces this struct from the action and a compiled
72/// policy. The verified core consumes it. No String, HashMap, glob, or serde
73/// operations are needed to compute the verdict from this struct.
74///
75/// # Fields
76///
77/// - `matched`: Whether the policy's tool/function pattern matched the action.
78/// - `is_deny`: Whether the policy type is `Deny`.
79/// - `is_conditional`: Whether the policy type is `Conditional`.
80/// - `priority`: The policy's priority (higher = evaluated first).
81/// - `rule_override_deny`: Whether path/network/IP rules forced a Deny.
82/// - `context_deny`: Whether context conditions produced a Deny.
83/// - `require_approval`: Whether the policy requires human approval.
84/// - `condition_fired`: For Conditional policies, whether any constraint matched.
85/// - `condition_verdict`: The verdict from the fired constraint (if any).
86/// - `on_no_match_continue`: For Conditional policies, whether to skip to next
87///   policy when no constraints fire (vs. implicit Allow).
88/// - `all_constraints_skipped`: For Conditional policies, whether every constraint
89///   was skipped due to missing parameters.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ResolvedMatch {
92    /// Whether the policy's tool/function pattern matched the action.
93    pub matched: bool,
94    /// Whether the policy type is `Deny`.
95    pub is_deny: bool,
96    /// Whether the policy type is `Conditional`.
97    pub is_conditional: bool,
98    /// Policy priority (higher = evaluated first in sorted order).
99    pub priority: u32,
100    /// Whether path/network/IP rules forced a Deny on this policy.
101    pub rule_override_deny: bool,
102    /// Whether context conditions produced a Deny.
103    pub context_deny: bool,
104    /// Whether the policy requires human approval (Conditional with require_approval).
105    pub require_approval: bool,
106    /// For Conditional policies: whether any constraint fired.
107    pub condition_fired: bool,
108    /// For Conditional policies: the verdict from the fired constraint.
109    pub condition_verdict: VerdictKind,
110    /// For Conditional policies: skip to next policy when no constraint fires.
111    pub on_no_match_continue: bool,
112    /// For Conditional policies: all constraints were skipped (missing params).
113    pub all_constraints_skipped: bool,
114}
115
116/// Outcome of verdict computation.
117///
118/// `Decided(VerdictKind)` means a final verdict was reached.
119/// `Continue` means a Conditional policy with `on_no_match="continue"` had
120/// no constraints fire, and the evaluation loop should try the next policy.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum VerdictOutcome {
123    /// A final verdict was reached.
124    Decided(VerdictKind),
125    /// No verdict from this policy — continue to the next one.
126    Continue,
127}
128
129/// Compute the verdict for a single resolved policy match.
130///
131/// This is the innermost verdict decision function. Given a fully-resolved
132/// match, it determines whether the match produces a verdict or should be
133/// skipped (Continue).
134///
135/// # Properties (per-policy)
136///
137/// - V4: `rule_override_deny == true` → `Decided(Deny)`
138/// - V3: `Allow` only when `!is_deny && !rule_override_deny && !context_deny`
139/// - V8: Conditional with unfired condition + `on_no_match_continue` → `Continue`
140#[inline]
141#[must_use = "security verdicts must not be discarded"]
142pub fn compute_single_verdict(rm: &ResolvedMatch) -> VerdictOutcome {
143    if !rm.matched {
144        return VerdictOutcome::Continue;
145    }
146
147    // V4: Rule override denials checked first (path/network/IP)
148    if rm.rule_override_deny {
149        return VerdictOutcome::Decided(VerdictKind::Deny);
150    }
151
152    // Context condition denials checked next
153    if rm.context_deny {
154        return VerdictOutcome::Decided(VerdictKind::Deny);
155    }
156
157    // Policy type dispatch
158    if rm.is_deny {
159        return VerdictOutcome::Decided(VerdictKind::Deny);
160    }
161
162    if rm.is_conditional {
163        // Require approval takes precedence
164        if rm.require_approval {
165            return VerdictOutcome::Decided(VerdictKind::RequireApproval);
166        }
167
168        // All constraints skipped (missing params) → fail-closed
169        if rm.all_constraints_skipped {
170            if rm.on_no_match_continue {
171                return VerdictOutcome::Continue;
172            }
173            return VerdictOutcome::Decided(VerdictKind::Deny);
174        }
175
176        if rm.condition_fired {
177            return VerdictOutcome::Decided(rm.condition_verdict);
178        }
179
180        // V8: No constraint fired — continue or implicit Allow
181        if rm.on_no_match_continue {
182            return VerdictOutcome::Continue;
183        }
184        return VerdictOutcome::Decided(VerdictKind::Allow);
185    }
186
187    // Allow policy — V3
188    VerdictOutcome::Decided(VerdictKind::Allow)
189}
190
191/// Compute the final verdict from a sequence of resolved policy matches.
192///
193/// The matches are expected to be in priority order (highest priority first,
194/// deny-first at equal priority). This function implements first-match-wins:
195/// it returns the first `Decided` verdict, or `Deny` if no policy produces one.
196///
197/// # Properties (V1-V8)
198///
199/// - **V1 (S1):** Empty `resolved` → Deny
200/// - **V2 (S1):** All `!matched` → Deny
201/// - **V3 (S5):** Allow → ∃ matching Allow policy with no override
202/// - **V4 (S3/S4):** Rule override on first match → Deny
203/// - **V5 (L1):** Always terminates (bounded by `resolved.len()`)
204/// - **V6 (S2):** First matching policy in sorted order determines verdict
205/// - **V7 (S3):** At equal priority, deny-sorted-first means Deny wins
206/// - **V8:** Conditional with unfired condition → skipped to next policy
207#[must_use = "security verdicts must not be discarded"]
208pub fn compute_verdict(resolved: &[ResolvedMatch]) -> VerdictKind {
209    // V1: Empty → Deny
210    // V5: Loop bounded by resolved.len()
211    for rm in resolved {
212        match compute_single_verdict(rm) {
213            VerdictOutcome::Decided(kind) => return kind,
214            VerdictOutcome::Continue => continue,
215        }
216    }
217    // V2: No match produced a verdict → Deny (fail-closed)
218    VerdictKind::Deny
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn allow_policy(priority: u32) -> ResolvedMatch {
226        ResolvedMatch {
227            matched: true,
228            is_deny: false,
229            is_conditional: false,
230            priority,
231            rule_override_deny: false,
232            context_deny: false,
233            require_approval: false,
234            condition_fired: false,
235            condition_verdict: VerdictKind::Deny,
236            on_no_match_continue: false,
237            all_constraints_skipped: false,
238        }
239    }
240
241    fn deny_policy(priority: u32) -> ResolvedMatch {
242        ResolvedMatch {
243            matched: true,
244            is_deny: true,
245            is_conditional: false,
246            priority,
247            rule_override_deny: false,
248            context_deny: false,
249            require_approval: false,
250            condition_fired: false,
251            condition_verdict: VerdictKind::Deny,
252            on_no_match_continue: false,
253            all_constraints_skipped: false,
254        }
255    }
256
257    fn unmatched_policy(priority: u32) -> ResolvedMatch {
258        ResolvedMatch {
259            matched: false,
260            is_deny: false,
261            is_conditional: false,
262            priority,
263            rule_override_deny: false,
264            context_deny: false,
265            require_approval: false,
266            condition_fired: false,
267            condition_verdict: VerdictKind::Deny,
268            on_no_match_continue: false,
269            all_constraints_skipped: false,
270        }
271    }
272
273    fn conditional_continue(priority: u32) -> ResolvedMatch {
274        ResolvedMatch {
275            matched: true,
276            is_deny: false,
277            is_conditional: true,
278            priority,
279            rule_override_deny: false,
280            context_deny: false,
281            require_approval: false,
282            condition_fired: false,
283            condition_verdict: VerdictKind::Deny,
284            on_no_match_continue: true,
285            all_constraints_skipped: false,
286        }
287    }
288
289    fn conditional_fired_allow(priority: u32) -> ResolvedMatch {
290        ResolvedMatch {
291            matched: true,
292            is_deny: false,
293            is_conditional: true,
294            priority,
295            rule_override_deny: false,
296            context_deny: false,
297            require_approval: false,
298            condition_fired: true,
299            condition_verdict: VerdictKind::Allow,
300            on_no_match_continue: false,
301            all_constraints_skipped: false,
302        }
303    }
304
305    fn conditional_fired_deny(priority: u32) -> ResolvedMatch {
306        ResolvedMatch {
307            matched: true,
308            is_deny: false,
309            is_conditional: true,
310            priority,
311            rule_override_deny: false,
312            context_deny: false,
313            require_approval: false,
314            condition_fired: true,
315            condition_verdict: VerdictKind::Deny,
316            on_no_match_continue: false,
317            all_constraints_skipped: false,
318        }
319    }
320
321    // === V1: Empty → Deny ===
322
323    #[test]
324    fn test_v1_empty_produces_deny() {
325        assert_eq!(compute_verdict(&[]), VerdictKind::Deny);
326    }
327
328    // === V2: All unmatched → Deny ===
329
330    #[test]
331    fn test_v2_all_unmatched_produces_deny() {
332        let resolved = vec![unmatched_policy(100), unmatched_policy(50)];
333        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
334    }
335
336    // === V3: Allow requires matching Allow policy ===
337
338    #[test]
339    fn test_v3_allow_from_allow_policy() {
340        let resolved = vec![allow_policy(100)];
341        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
342    }
343
344    #[test]
345    fn test_v3_allow_not_from_deny_policy() {
346        let resolved = vec![deny_policy(100)];
347        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
348    }
349
350    #[test]
351    fn test_v3_allow_not_from_rule_override() {
352        let mut rm = allow_policy(100);
353        rm.rule_override_deny = true;
354        assert_eq!(compute_verdict(&[rm]), VerdictKind::Deny);
355    }
356
357    #[test]
358    fn test_v3_allow_not_from_context_deny() {
359        let mut rm = allow_policy(100);
360        rm.context_deny = true;
361        assert_eq!(compute_verdict(&[rm]), VerdictKind::Deny);
362    }
363
364    // === V4: Rule override → Deny ===
365
366    #[test]
367    fn test_v4_rule_override_forces_deny() {
368        let mut rm = allow_policy(100);
369        rm.rule_override_deny = true;
370        assert_eq!(
371            compute_single_verdict(&rm),
372            VerdictOutcome::Decided(VerdictKind::Deny)
373        );
374    }
375
376    #[test]
377    fn test_v4_rule_override_on_deny_policy() {
378        let mut rm = deny_policy(100);
379        rm.rule_override_deny = true;
380        assert_eq!(
381            compute_single_verdict(&rm),
382            VerdictOutcome::Decided(VerdictKind::Deny)
383        );
384    }
385
386    #[test]
387    fn test_v4_rule_override_on_conditional() {
388        let mut rm = conditional_fired_allow(100);
389        rm.rule_override_deny = true;
390        assert_eq!(
391            compute_single_verdict(&rm),
392            VerdictOutcome::Decided(VerdictKind::Deny)
393        );
394    }
395
396    // === V5: Totality (always terminates) ===
397    // Implicitly tested by all tests completing.
398
399    // === V6: Priority ordering (first-match-wins in sorted order) ===
400
401    #[test]
402    fn test_v6_higher_priority_deny_wins() {
403        // Sorted: deny(100) before allow(50)
404        let resolved = vec![deny_policy(100), allow_policy(50)];
405        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
406    }
407
408    #[test]
409    fn test_v6_higher_priority_allow_wins() {
410        // Sorted: allow(100) before deny(50)
411        let resolved = vec![allow_policy(100), deny_policy(50)];
412        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
413    }
414
415    // === V7: Deny-dominance at equal priority ===
416
417    #[test]
418    fn test_v7_deny_before_allow_at_equal_priority() {
419        // When sorted correctly: deny(100) before allow(100) at same priority
420        let resolved = vec![deny_policy(100), allow_policy(100)];
421        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
422    }
423
424    // === V8: Conditional pass-through ===
425
426    #[test]
427    fn test_v8_conditional_continue_skips_to_next() {
428        assert_eq!(
429            compute_single_verdict(&conditional_continue(100)),
430            VerdictOutcome::Continue,
431        );
432    }
433
434    #[test]
435    fn test_v8_conditional_continue_then_allow() {
436        let resolved = vec![conditional_continue(100), allow_policy(50)];
437        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
438    }
439
440    #[test]
441    fn test_v8_conditional_continue_then_deny() {
442        let resolved = vec![conditional_continue(100), deny_policy(50)];
443        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
444    }
445
446    #[test]
447    fn test_v8_all_conditional_continue_produces_deny() {
448        let resolved = vec![conditional_continue(100), conditional_continue(50)];
449        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
450    }
451
452    // === Conditional constraint fired ===
453
454    #[test]
455    fn test_conditional_fired_allow() {
456        let resolved = vec![conditional_fired_allow(100)];
457        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
458    }
459
460    #[test]
461    fn test_conditional_fired_deny() {
462        let resolved = vec![conditional_fired_deny(100)];
463        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
464    }
465
466    // === Conditional without on_no_match_continue: implicit Allow ===
467
468    #[test]
469    fn test_conditional_no_fire_no_continue_implicit_allow() {
470        let mut rm = conditional_continue(100);
471        rm.on_no_match_continue = false;
472        assert_eq!(
473            compute_single_verdict(&rm),
474            VerdictOutcome::Decided(VerdictKind::Allow),
475        );
476    }
477
478    // === Require approval ===
479
480    #[test]
481    fn test_require_approval_verdict() {
482        let mut rm = conditional_continue(100);
483        rm.require_approval = true;
484        assert_eq!(
485            compute_single_verdict(&rm),
486            VerdictOutcome::Decided(VerdictKind::RequireApproval),
487        );
488    }
489
490    // === All constraints skipped (fail-closed) ===
491
492    #[test]
493    fn test_all_constraints_skipped_continue() {
494        let mut rm = conditional_continue(100);
495        rm.all_constraints_skipped = true;
496        assert_eq!(compute_single_verdict(&rm), VerdictOutcome::Continue,);
497    }
498
499    #[test]
500    fn test_all_constraints_skipped_no_continue_deny() {
501        let mut rm = conditional_continue(100);
502        rm.all_constraints_skipped = true;
503        rm.on_no_match_continue = false;
504        assert_eq!(
505            compute_single_verdict(&rm),
506            VerdictOutcome::Decided(VerdictKind::Deny),
507        );
508    }
509
510    // === Context deny ===
511
512    #[test]
513    fn test_context_deny_overrides_allow() {
514        let mut rm = allow_policy(100);
515        rm.context_deny = true;
516        assert_eq!(compute_verdict(&[rm]), VerdictKind::Deny);
517    }
518
519    #[test]
520    fn test_context_deny_on_conditional_overrides_fired_allow() {
521        let mut rm = conditional_fired_allow(100);
522        rm.context_deny = true;
523        assert_eq!(compute_verdict(&[rm]), VerdictKind::Deny);
524    }
525
526    // === Mixed sequences ===
527
528    #[test]
529    fn test_mixed_unmatched_then_allow() {
530        let resolved = vec![unmatched_policy(200), allow_policy(100)];
531        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
532    }
533
534    #[test]
535    fn test_mixed_continue_then_continue_then_deny() {
536        let resolved = vec![
537            conditional_continue(100),
538            conditional_continue(90),
539            deny_policy(80),
540        ];
541        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
542    }
543
544    #[test]
545    fn test_conditional_fired_require_approval() {
546        let mut rm = conditional_continue(100);
547        rm.condition_fired = true;
548        rm.condition_verdict = VerdictKind::RequireApproval;
549        assert_eq!(
550            compute_single_verdict(&rm),
551            VerdictOutcome::Decided(VerdictKind::RequireApproval),
552        );
553    }
554
555    #[test]
556    fn test_rule_override_before_context_deny() {
557        let mut rm = allow_policy(100);
558        rm.rule_override_deny = true;
559        rm.context_deny = true;
560        // Rule override checked first
561        assert_eq!(
562            compute_single_verdict(&rm),
563            VerdictOutcome::Decided(VerdictKind::Deny),
564        );
565    }
566
567    // === Complex multi-policy scenarios ===
568
569    #[test]
570    fn test_many_unmatched_then_conditional_fired_allow() {
571        let resolved = vec![
572            unmatched_policy(200),
573            unmatched_policy(150),
574            unmatched_policy(100),
575            conditional_fired_allow(50),
576        ];
577        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
578    }
579
580    #[test]
581    fn test_conditional_chain_with_fired_deny_at_end() {
582        let resolved = vec![
583            conditional_continue(100),
584            conditional_continue(90),
585            conditional_continue(80),
586            conditional_fired_deny(70),
587        ];
588        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
589    }
590
591    #[test]
592    fn test_rule_override_on_first_match_skips_later_allow() {
593        let mut overridden = allow_policy(100);
594        overridden.rule_override_deny = true;
595        let resolved = vec![overridden, allow_policy(50)];
596        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
597    }
598
599    #[test]
600    fn test_context_deny_on_first_match_skips_later_allow() {
601        let mut ctx_deny = allow_policy(100);
602        ctx_deny.context_deny = true;
603        let resolved = vec![ctx_deny, allow_policy(50)];
604        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
605    }
606
607    #[test]
608    fn test_require_approval_in_conditional_chain() {
609        let mut approval = conditional_continue(90);
610        approval.require_approval = true;
611        let resolved = vec![conditional_continue(100), approval];
612        assert_eq!(compute_verdict(&resolved), VerdictKind::RequireApproval);
613    }
614
615    #[test]
616    fn test_mixed_unmatched_continue_deny() {
617        let resolved = vec![
618            unmatched_policy(200),
619            conditional_continue(150),
620            unmatched_policy(100),
621            deny_policy(50),
622        ];
623        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
624    }
625
626    #[test]
627    fn test_all_constraints_skipped_in_chain_then_allow() {
628        let mut skipped = conditional_continue(100);
629        skipped.all_constraints_skipped = true;
630        let resolved = vec![skipped, allow_policy(50)];
631        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
632    }
633
634    #[test]
635    fn test_all_constraints_skipped_no_continue_in_chain() {
636        let mut skipped = conditional_continue(100);
637        skipped.all_constraints_skipped = true;
638        skipped.on_no_match_continue = false;
639        let resolved = vec![skipped, allow_policy(50)];
640        // Fail-closed: all_constraints_skipped + no continue = Deny
641        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
642    }
643
644    #[test]
645    fn test_single_unmatched_produces_deny() {
646        let resolved = vec![unmatched_policy(100)];
647        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
648    }
649
650    #[test]
651    fn test_conditional_implicit_allow_when_no_continue() {
652        let mut rm = conditional_continue(100);
653        rm.on_no_match_continue = false;
654        // No constraint fired, no continue → implicit Allow
655        let resolved = vec![rm];
656        assert_eq!(compute_verdict(&resolved), VerdictKind::Allow);
657    }
658
659    #[test]
660    fn test_large_policy_set_first_match_deny() {
661        let mut resolved: Vec<ResolvedMatch> = (0..50).map(|i| unmatched_policy(200 - i)).collect();
662        resolved.push(deny_policy(100));
663        // Add some more unmatched after
664        for i in 0..20 {
665            resolved.push(unmatched_policy(50 - i));
666        }
667        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
668    }
669
670    #[test]
671    fn test_large_policy_set_all_unmatched() {
672        let resolved: Vec<ResolvedMatch> = (0..100).map(|i| unmatched_policy(200 - i)).collect();
673        assert_eq!(compute_verdict(&resolved), VerdictKind::Deny);
674    }
675
676    #[test]
677    fn test_conditional_fired_deny_verdict_from_constraint() {
678        let mut rm = conditional_continue(100);
679        rm.condition_fired = true;
680        rm.condition_verdict = VerdictKind::Deny;
681        assert_eq!(
682            compute_single_verdict(&rm),
683            VerdictOutcome::Decided(VerdictKind::Deny),
684        );
685    }
686}