1use crate::PolicyEngine;
15use vellaveto_types::{Action, Policy, Verdict};
16
17const MAX_VERDICT_CHANGES: usize = 100_000;
22
23#[derive(Debug, Clone)]
25pub struct VerdictChange {
26 pub action_tool: String,
28 pub action_function: String,
30 pub original_verdict: VerdictSummary,
32 pub new_verdict: VerdictSummary,
34 pub timestamp: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum VerdictSummary {
45 Allow,
46 Deny,
47 RequireApproval,
48}
49
50#[derive(Debug, Clone)]
52pub struct ImpactReport {
53 pub actions_replayed: usize,
55 pub verdict_changes: Vec<VerdictChange>,
57 pub newly_denied: usize,
59 pub newly_allowed: usize,
61 pub unchanged: usize,
63 pub errors: usize,
65}
66
67#[derive(Debug, Clone)]
69pub struct HistoricalAction {
70 pub action: Action,
72 pub original_verdict: VerdictSummary,
74 pub timestamp: String,
76}
77
78pub struct ImpactAnalyzer;
81
82impl ImpactAnalyzer {
83 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 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 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 _ => 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 #[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 #[test]
230 fn test_analyze_all_unchanged_same_policies() {
231 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 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 #[test]
250 fn test_analyze_allow_to_deny_policy_removed() {
251 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 #[test]
270 fn test_analyze_deny_to_allow_policy_changed() {
271 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 #[test]
292 fn test_analyze_mixed_changes_correct_counts() {
293 let historical = vec![
294 make_historical("unknown", "op", VerdictSummary::Deny),
296 make_historical("bash", "execute", VerdictSummary::Allow),
298 make_historical("file_system", "read_file", VerdictSummary::Deny),
300 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); assert_eq!(report.newly_allowed, 1); assert_eq!(report.unchanged, 2); assert_eq!(report.verdict_changes.len(), 2);
310 }
311
312 #[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 let report = ImpactAnalyzer::analyze(&[], &historical, false);
324 assert_eq!(report.newly_denied, 2); assert_eq!(report.newly_allowed, 0);
326 assert_eq!(report.unchanged, 1); }
328
329 #[test]
333 fn test_analyze_require_approval_to_deny() {
334 let historical = vec![make_historical(
336 "sensitive",
337 "op",
338 VerdictSummary::RequireApproval,
339 )];
340 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 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 #[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 let report_non_strict = ImpactAnalyzer::analyze(&policies, &historical, false);
379 assert_eq!(report_non_strict.unchanged, 1);
380
381 let report_strict = ImpactAnalyzer::analyze(&policies, &historical, true);
383 assert_eq!(report_strict.unchanged, 1);
384 }
385
386 #[test]
390 fn test_analyze_error_counting() {
391 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 assert_eq!(report.errors, 0);
400 assert_eq!(report.actions_replayed, 2);
402 }
403
404 #[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 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 #[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 #[test]
455 fn test_impact_report_no_changes() {
456 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 #[test]
473 fn test_analyze_overlapping_policies() {
474 let historical = vec![make_historical("bash", "execute", VerdictSummary::Allow)];
476 let policies = vec![
477 make_deny_policy("bash:*"), make_allow_policy("bash:execute"), ];
480 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 #[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 #[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 #[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); assert_eq!(report.unchanged, 1); }
542
543 #[test]
547 fn test_analyze_saturating_counters() {
548 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 assert_eq!(
558 report.newly_denied + report.newly_allowed + report.unchanged + report.errors,
559 report.actions_replayed
560 );
561 }
562}