Skip to main content

vellaveto_engine/
impact.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//! Policy Impact Analysis
9//!
10//! Replays historical audit entries against a candidate policy set to identify
11//! verdict changes. This enables operators to preview the effect of policy
12//! changes before deploying them.
13
14use crate::PolicyEngine;
15use vellaveto_types::{Action, Policy, Verdict};
16
17/// Maximum number of verdict changes stored in an impact report.
18///
19/// SECURITY: Prevents unbounded memory growth when replaying large
20/// historical datasets where every action changes verdict.
21const MAX_VERDICT_CHANGES: usize = 100_000;
22
23/// A single verdict change detected during impact analysis.
24#[derive(Debug, Clone)]
25pub struct VerdictChange {
26    /// The tool name from the original action.
27    pub action_tool: String,
28    /// The function name from the original action.
29    pub action_function: String,
30    /// The verdict that was originally recorded.
31    pub original_verdict: VerdictSummary,
32    /// The verdict produced by the candidate policy set.
33    pub new_verdict: VerdictSummary,
34    /// The timestamp of the original action.
35    pub timestamp: String,
36}
37
38/// Simplified verdict for comparison (no reason strings).
39///
40/// Strips the `reason` field from [`Verdict`] so that impact analysis
41/// compares disposition (Allow/Deny/RequireApproval) rather than
42/// message text.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum VerdictSummary {
45    Allow,
46    Deny,
47    RequireApproval,
48}
49
50/// Impact analysis result summarizing verdict changes.
51#[derive(Debug, Clone)]
52pub struct ImpactReport {
53    /// Total number of historical actions replayed.
54    pub actions_replayed: usize,
55    /// List of actions whose verdict changed.
56    pub verdict_changes: Vec<VerdictChange>,
57    /// Count of actions that were Allow or RequireApproval but are now Deny.
58    pub newly_denied: usize,
59    /// Count of actions that were Deny but are now Allow.
60    pub newly_allowed: usize,
61    /// Count of actions whose verdict did not change.
62    pub unchanged: usize,
63    /// Count of actions that caused evaluation errors.
64    pub errors: usize,
65}
66
67/// Historical action for replay (extracted from audit entries).
68#[derive(Debug, Clone)]
69pub struct HistoricalAction {
70    /// The action that was originally evaluated.
71    pub action: Action,
72    /// The verdict that was originally produced.
73    pub original_verdict: VerdictSummary,
74    /// The ISO 8601 timestamp of the original evaluation.
75    pub timestamp: String,
76}
77
78/// Stateless impact analyzer that replays historical actions against
79/// candidate policy sets.
80pub struct ImpactAnalyzer;
81
82impl ImpactAnalyzer {
83    /// Analyze the impact of a candidate policy set against historical actions.
84    ///
85    /// Replays each historical action against the candidate policies and compares
86    /// the new verdict to the original. Returns an [`ImpactReport`] summarizing
87    /// how many actions would change disposition.
88    ///
89    /// # Arguments
90    ///
91    /// * `candidate_policies` — The new policy set to evaluate against.
92    /// * `historical` — Historical actions with their original verdicts.
93    /// * `strict_mode` — Whether the engine should use strict mode.
94    ///
95    /// # Fail-closed behavior
96    ///
97    /// Actions that cause evaluation errors are counted in `errors` and do
98    /// not appear in `verdict_changes`. This prevents error cases from being
99    /// misinterpreted as verdict changes.
100    pub fn analyze(
101        candidate_policies: &[Policy],
102        historical: &[HistoricalAction],
103        strict_mode: bool,
104    ) -> ImpactReport {
105        let engine = PolicyEngine::new(strict_mode);
106        let mut changes = Vec::new();
107        let mut newly_denied = 0usize;
108        let mut newly_allowed = 0usize;
109        let mut unchanged = 0usize;
110        let mut errors = 0usize;
111
112        for hist in historical {
113            match engine.evaluate_action(&hist.action, candidate_policies) {
114                Ok(new_verdict) => {
115                    let new_summary = VerdictSummary::from(&new_verdict);
116                    if new_summary != hist.original_verdict {
117                        // Track direction of change
118                        match (hist.original_verdict, new_summary) {
119                            (VerdictSummary::Allow, VerdictSummary::Deny)
120                            | (VerdictSummary::RequireApproval, VerdictSummary::Deny) => {
121                                newly_denied = newly_denied.saturating_add(1);
122                            }
123                            (VerdictSummary::Deny, VerdictSummary::Allow) => {
124                                newly_allowed = newly_allowed.saturating_add(1);
125                            }
126                            _ => {}
127                        }
128                        // SECURITY: Bound the changes vector to prevent unbounded growth.
129                        if changes.len() < MAX_VERDICT_CHANGES {
130                            changes.push(VerdictChange {
131                                action_tool: hist.action.tool.clone(),
132                                action_function: hist.action.function.clone(),
133                                original_verdict: hist.original_verdict,
134                                new_verdict: new_summary,
135                                timestamp: hist.timestamp.clone(),
136                            });
137                        }
138                    } else {
139                        unchanged = unchanged.saturating_add(1);
140                    }
141                }
142                Err(_) => {
143                    errors = errors.saturating_add(1);
144                }
145            }
146        }
147
148        ImpactReport {
149            actions_replayed: historical.len(),
150            verdict_changes: changes,
151            newly_denied,
152            newly_allowed,
153            unchanged,
154            errors,
155        }
156    }
157}
158
159impl From<&Verdict> for VerdictSummary {
160    fn from(v: &Verdict) -> Self {
161        match v {
162            Verdict::Allow => VerdictSummary::Allow,
163            Verdict::Deny { .. } => VerdictSummary::Deny,
164            Verdict::RequireApproval { .. } => VerdictSummary::RequireApproval,
165            // Verdict is #[non_exhaustive]; fail-closed to Deny for unknown variants.
166            _ => VerdictSummary::Deny,
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use serde_json::json;
175    use vellaveto_types::PolicyType;
176
177    fn make_action(tool: &str, function: &str) -> Action {
178        Action::new(tool.to_string(), function.to_string(), json!({}))
179    }
180
181    fn make_historical(tool: &str, function: &str, verdict: VerdictSummary) -> HistoricalAction {
182        HistoricalAction {
183            action: make_action(tool, function),
184            original_verdict: verdict,
185            timestamp: "2026-02-26T00:00:00Z".to_string(),
186        }
187    }
188
189    fn make_allow_policy(id: &str) -> Policy {
190        Policy {
191            id: id.to_string(),
192            name: format!("Allow {id}"),
193            policy_type: PolicyType::Allow,
194            priority: 50,
195            path_rules: None,
196            network_rules: None,
197        }
198    }
199
200    fn make_deny_policy(id: &str) -> Policy {
201        Policy {
202            id: id.to_string(),
203            name: format!("Deny {id}"),
204            policy_type: PolicyType::Deny,
205            priority: 100,
206            path_rules: None,
207            network_rules: None,
208        }
209    }
210
211    // ---------------------------------------------------------------
212    // 1. Empty historical actions → zero changes
213    // ---------------------------------------------------------------
214    #[test]
215    fn test_analyze_empty_history_zero_changes() {
216        let policies = vec![make_allow_policy("bash:*")];
217        let report = ImpactAnalyzer::analyze(&policies, &[], false);
218        assert_eq!(report.actions_replayed, 0);
219        assert_eq!(report.verdict_changes.len(), 0);
220        assert_eq!(report.newly_denied, 0);
221        assert_eq!(report.newly_allowed, 0);
222        assert_eq!(report.unchanged, 0);
223        assert_eq!(report.errors, 0);
224    }
225
226    // ---------------------------------------------------------------
227    // 2. All unchanged (same policies produce same verdicts)
228    // ---------------------------------------------------------------
229    #[test]
230    fn test_analyze_all_unchanged_same_policies() {
231        // With no policies, engine denies everything. If original was Deny, unchanged.
232        let historical = vec![
233            make_historical("bash", "execute", VerdictSummary::Deny),
234            make_historical("curl", "fetch", VerdictSummary::Deny),
235            make_historical("unknown", "op", VerdictSummary::Deny),
236        ];
237        // No policies → engine denies all → matches Deny original
238        let report = ImpactAnalyzer::analyze(&[], &historical, false);
239        assert_eq!(report.actions_replayed, 3);
240        assert_eq!(report.unchanged, 3);
241        assert_eq!(report.verdict_changes.len(), 0);
242        assert_eq!(report.newly_denied, 0);
243        assert_eq!(report.newly_allowed, 0);
244    }
245
246    // ---------------------------------------------------------------
247    // 3. Allow → Deny when allow policy removed
248    // ---------------------------------------------------------------
249    #[test]
250    fn test_analyze_allow_to_deny_policy_removed() {
251        // Originally allowed by bash:* policy, now no policies → Deny
252        let historical = vec![make_historical("bash", "execute", VerdictSummary::Allow)];
253        let report = ImpactAnalyzer::analyze(&[], &historical, false);
254        assert_eq!(report.actions_replayed, 1);
255        assert_eq!(report.newly_denied, 1);
256        assert_eq!(report.newly_allowed, 0);
257        assert_eq!(report.unchanged, 0);
258        assert_eq!(report.verdict_changes.len(), 1);
259        let change = &report.verdict_changes[0];
260        assert_eq!(change.action_tool, "bash");
261        assert_eq!(change.action_function, "execute");
262        assert_eq!(change.original_verdict, VerdictSummary::Allow);
263        assert_eq!(change.new_verdict, VerdictSummary::Deny);
264    }
265
266    // ---------------------------------------------------------------
267    // 4. Deny → Allow when deny policy removed and allow policy added
268    // ---------------------------------------------------------------
269    #[test]
270    fn test_analyze_deny_to_allow_policy_changed() {
271        // Originally denied, now allow policy present → Allow
272        let historical = vec![make_historical(
273            "file_system",
274            "read_file",
275            VerdictSummary::Deny,
276        )];
277        let policies = vec![make_allow_policy("file_system:read_file")];
278        let report = ImpactAnalyzer::analyze(&policies, &historical, false);
279        assert_eq!(report.newly_allowed, 1);
280        assert_eq!(report.newly_denied, 0);
281        assert_eq!(report.unchanged, 0);
282        assert_eq!(report.verdict_changes.len(), 1);
283        let change = &report.verdict_changes[0];
284        assert_eq!(change.original_verdict, VerdictSummary::Deny);
285        assert_eq!(change.new_verdict, VerdictSummary::Allow);
286    }
287
288    // ---------------------------------------------------------------
289    // 5. Mixed changes with correct counts
290    // ---------------------------------------------------------------
291    #[test]
292    fn test_analyze_mixed_changes_correct_counts() {
293        let historical = vec![
294            // Will stay Deny (no matching policy)
295            make_historical("unknown", "op", VerdictSummary::Deny),
296            // Was Allow, now Deny (no policy)
297            make_historical("bash", "execute", VerdictSummary::Allow),
298            // Was Deny, now Allow (policy added)
299            make_historical("file_system", "read_file", VerdictSummary::Deny),
300            // Will match allow → stays Allow
301            make_historical("file_system", "read_file", VerdictSummary::Allow),
302        ];
303        let policies = vec![make_allow_policy("file_system:read_file")];
304        let report = ImpactAnalyzer::analyze(&policies, &historical, false);
305        assert_eq!(report.actions_replayed, 4);
306        assert_eq!(report.newly_denied, 1); // bash Allow→Deny
307        assert_eq!(report.newly_allowed, 1); // file_system Deny→Allow
308        assert_eq!(report.unchanged, 2); // unknown stays Deny, file_system stays Allow
309        assert_eq!(report.verdict_changes.len(), 2);
310    }
311
312    // ---------------------------------------------------------------
313    // 6. newly_denied and newly_allowed counters correct
314    // ---------------------------------------------------------------
315    #[test]
316    fn test_analyze_counter_accuracy() {
317        let historical = vec![
318            make_historical("a", "f1", VerdictSummary::Allow),
319            make_historical("b", "f2", VerdictSummary::Allow),
320            make_historical("c", "f3", VerdictSummary::Deny),
321        ];
322        // No policies → all become Deny
323        let report = ImpactAnalyzer::analyze(&[], &historical, false);
324        assert_eq!(report.newly_denied, 2); // a and b: Allow→Deny
325        assert_eq!(report.newly_allowed, 0);
326        assert_eq!(report.unchanged, 1); // c stays Deny
327    }
328
329    // ---------------------------------------------------------------
330    // 7. RequireApproval transitions
331    // ---------------------------------------------------------------
332    #[test]
333    fn test_analyze_require_approval_to_deny() {
334        // RequireApproval → Deny counts as newly_denied
335        let historical = vec![make_historical(
336            "sensitive",
337            "op",
338            VerdictSummary::RequireApproval,
339        )];
340        // No policies → Deny
341        let report = ImpactAnalyzer::analyze(&[], &historical, false);
342        assert_eq!(report.newly_denied, 1);
343        assert_eq!(report.verdict_changes.len(), 1);
344        assert_eq!(
345            report.verdict_changes[0].original_verdict,
346            VerdictSummary::RequireApproval
347        );
348        assert_eq!(report.verdict_changes[0].new_verdict, VerdictSummary::Deny);
349    }
350
351    #[test]
352    fn test_analyze_deny_to_require_approval_not_newly_allowed() {
353        // Deny → RequireApproval is a change but not newly_allowed (not Allow)
354        // We need a Conditional policy to produce RequireApproval
355        // With standard policies we can't directly produce RequireApproval from
356        // evaluate_action, but let's test the counter logic by verifying that
357        // Deny→Allow is counted and Deny→Deny is unchanged.
358        let historical = vec![make_historical("bash", "execute", VerdictSummary::Deny)];
359        let policies = vec![make_allow_policy("bash:execute")];
360        let report = ImpactAnalyzer::analyze(&policies, &historical, false);
361        assert_eq!(report.newly_allowed, 1);
362        assert_eq!(report.newly_denied, 0);
363    }
364
365    // ---------------------------------------------------------------
366    // 8. Strict mode vs non-strict mode behavior
367    // ---------------------------------------------------------------
368    #[test]
369    fn test_analyze_strict_mode() {
370        let historical = vec![make_historical(
371            "file_system",
372            "read_file",
373            VerdictSummary::Allow,
374        )];
375        let policies = vec![make_allow_policy("file_system:read_file")];
376
377        // Non-strict: should match and remain Allow
378        let report_non_strict = ImpactAnalyzer::analyze(&policies, &historical, false);
379        assert_eq!(report_non_strict.unchanged, 1);
380
381        // Strict: same result (strict mode affects constraint evaluation, not basic matching)
382        let report_strict = ImpactAnalyzer::analyze(&policies, &historical, true);
383        assert_eq!(report_strict.unchanged, 1);
384    }
385
386    // ---------------------------------------------------------------
387    // 9. Error counting
388    // ---------------------------------------------------------------
389    #[test]
390    fn test_analyze_error_counting() {
391        // The legacy path with empty policies returns Deny, it doesn't error.
392        // To trigger errors we can verify that well-formed actions don't error.
393        let historical = vec![
394            make_historical("bash", "execute", VerdictSummary::Deny),
395            make_historical("file_system", "read", VerdictSummary::Allow),
396        ];
397        let report = ImpactAnalyzer::analyze(&[], &historical, false);
398        // With the legacy path and empty policies, no errors expected
399        assert_eq!(report.errors, 0);
400        // All should be evaluated: bash stays Deny, file_system was Allow now Deny
401        assert_eq!(report.actions_replayed, 2);
402    }
403
404    // ---------------------------------------------------------------
405    // 10. Large replay set (500 historical actions)
406    // ---------------------------------------------------------------
407    #[test]
408    fn test_analyze_large_replay_set() {
409        let historical: Vec<HistoricalAction> = (0..500)
410            .map(|i| {
411                let tool = format!("tool_{i}");
412                make_historical(&tool, "op", VerdictSummary::Allow)
413            })
414            .collect();
415        // No policies → all Deny → all 500 change from Allow to Deny
416        let report = ImpactAnalyzer::analyze(&[], &historical, false);
417        assert_eq!(report.actions_replayed, 500);
418        assert_eq!(report.newly_denied, 500);
419        assert_eq!(report.unchanged, 0);
420        assert_eq!(report.verdict_changes.len(), 500);
421    }
422
423    // ---------------------------------------------------------------
424    // 11. VerdictSummary From impl for all 3 variants
425    // ---------------------------------------------------------------
426    #[test]
427    fn test_verdict_summary_from_allow() {
428        let v = Verdict::Allow;
429        let s = VerdictSummary::from(&v);
430        assert_eq!(s, VerdictSummary::Allow);
431    }
432
433    #[test]
434    fn test_verdict_summary_from_deny() {
435        let v = Verdict::Deny {
436            reason: "blocked".to_string(),
437        };
438        let s = VerdictSummary::from(&v);
439        assert_eq!(s, VerdictSummary::Deny);
440    }
441
442    #[test]
443    fn test_verdict_summary_from_require_approval() {
444        let v = Verdict::RequireApproval {
445            reason: "needs review".to_string(),
446        };
447        let s = VerdictSummary::from(&v);
448        assert_eq!(s, VerdictSummary::RequireApproval);
449    }
450
451    // ---------------------------------------------------------------
452    // 12. ImpactReport with no changes
453    // ---------------------------------------------------------------
454    #[test]
455    fn test_impact_report_no_changes() {
456        // All originally Deny, no policies → still Deny → no changes
457        let historical = vec![
458            make_historical("a", "x", VerdictSummary::Deny),
459            make_historical("b", "y", VerdictSummary::Deny),
460        ];
461        let report = ImpactAnalyzer::analyze(&[], &historical, false);
462        assert_eq!(report.verdict_changes.len(), 0);
463        assert_eq!(report.unchanged, 2);
464        assert_eq!(report.newly_denied, 0);
465        assert_eq!(report.newly_allowed, 0);
466        assert_eq!(report.errors, 0);
467    }
468
469    // ---------------------------------------------------------------
470    // 13. Overlapping policy changes
471    // ---------------------------------------------------------------
472    #[test]
473    fn test_analyze_overlapping_policies() {
474        // A deny policy at higher priority overrides an allow policy
475        let historical = vec![make_historical("bash", "execute", VerdictSummary::Allow)];
476        let policies = vec![
477            make_deny_policy("bash:*"),        // priority 100
478            make_allow_policy("bash:execute"), // priority 50
479        ];
480        // Deny at priority 100 wins → Allow becomes Deny
481        let report = ImpactAnalyzer::analyze(&policies, &historical, false);
482        assert_eq!(report.newly_denied, 1);
483        assert_eq!(report.verdict_changes.len(), 1);
484        assert_eq!(report.verdict_changes[0].new_verdict, VerdictSummary::Deny);
485    }
486
487    // ---------------------------------------------------------------
488    // 14. VerdictChange captures correct fields
489    // ---------------------------------------------------------------
490    #[test]
491    fn test_verdict_change_fields_populated() {
492        let historical = vec![HistoricalAction {
493            action: make_action("my_tool", "my_func"),
494            original_verdict: VerdictSummary::Allow,
495            timestamp: "2026-01-15T12:30:00Z".to_string(),
496        }];
497        let report = ImpactAnalyzer::analyze(&[], &historical, false);
498        assert_eq!(report.verdict_changes.len(), 1);
499        let change = &report.verdict_changes[0];
500        assert_eq!(change.action_tool, "my_tool");
501        assert_eq!(change.action_function, "my_func");
502        assert_eq!(change.timestamp, "2026-01-15T12:30:00Z");
503        assert_eq!(change.original_verdict, VerdictSummary::Allow);
504        assert_eq!(change.new_verdict, VerdictSummary::Deny);
505    }
506
507    // ---------------------------------------------------------------
508    // 15. Multiple deny-to-allow transitions
509    // ---------------------------------------------------------------
510    #[test]
511    fn test_analyze_multiple_deny_to_allow() {
512        let historical = vec![
513            make_historical("file_system", "read_file", VerdictSummary::Deny),
514            make_historical("file_system", "write_file", VerdictSummary::Deny),
515            make_historical("file_system", "list_dir", VerdictSummary::Deny),
516        ];
517        let policies = vec![make_allow_policy("file_system:*")];
518        let report = ImpactAnalyzer::analyze(&policies, &historical, false);
519        assert_eq!(report.newly_allowed, 3);
520        assert_eq!(report.newly_denied, 0);
521        assert_eq!(report.unchanged, 0);
522        assert_eq!(report.verdict_changes.len(), 3);
523        for change in &report.verdict_changes {
524            assert_eq!(change.original_verdict, VerdictSummary::Deny);
525            assert_eq!(change.new_verdict, VerdictSummary::Allow);
526        }
527    }
528
529    // ---------------------------------------------------------------
530    // 16. Empty candidate policies (all become Deny)
531    // ---------------------------------------------------------------
532    #[test]
533    fn test_analyze_empty_candidate_policies() {
534        let historical = vec![
535            make_historical("a", "b", VerdictSummary::Allow),
536            make_historical("c", "d", VerdictSummary::Deny),
537        ];
538        let report = ImpactAnalyzer::analyze(&[], &historical, false);
539        assert_eq!(report.newly_denied, 1); // a was Allow, now Deny
540        assert_eq!(report.unchanged, 1); // c stays Deny
541    }
542
543    // ---------------------------------------------------------------
544    // 17. Saturating counters don't overflow
545    // ---------------------------------------------------------------
546    #[test]
547    fn test_analyze_saturating_counters() {
548        // Verify the report compiles and counters are correct for a moderate set
549        let historical: Vec<HistoricalAction> = (0..100)
550            .map(|i| make_historical(&format!("t{i}"), "f", VerdictSummary::Allow))
551            .collect();
552        let report = ImpactAnalyzer::analyze(&[], &historical, false);
553        assert_eq!(report.newly_denied, 100);
554        assert_eq!(report.unchanged, 0);
555        assert_eq!(report.errors, 0);
556        // Verify total adds up
557        assert_eq!(
558            report.newly_denied + report.newly_allowed + report.unchanged + report.errors,
559            report.actions_replayed
560        );
561    }
562}