Skip to main content

tirith_core/
escalation.rs

1//! Escalation engine and post-processing verdict helper.
2//!
3//! Escalation rules upgrade the verdict action based on session warning history
4//! (repeat offenders) or current finding density (multi-medium).
5//!
6//! The `post_process_verdict` function is the shared helper that applies action
7//! overrides, approvals, paranoia filtering, escalation, and session recording
8//! in the correct order.
9
10use std::collections::{HashMap, HashSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::session_warnings::SessionWarnings;
15use crate::verdict::{Action, Finding, Severity, Verdict};
16
17fn default_window_60() -> u64 {
18    60
19}
20
21/// An escalation rule: upgrade verdict action based on session history or
22/// current finding count.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "trigger", rename_all = "snake_case")]
25pub enum EscalationRule {
26    /// Upgrade to `action` when a matching rule has fired >= `threshold` times
27    /// within `window_minutes` (counting both session history AND current findings).
28    RepeatCount {
29        rule_ids: Vec<String>,
30        threshold: u32,
31        #[serde(default = "default_window_60")]
32        window_minutes: u64,
33        action: EscalationAction,
34        #[serde(default)]
35        domain_scoped: bool,
36        /// Minutes after an escalation fires during which that (rule, domain) pair
37        /// will not re-escalate. 0 = no cooldown (default, preserves old behaviour).
38        #[serde(default)]
39        cooldown_minutes: u64,
40    },
41    /// Upgrade when the current verdict contains >= `min_findings` findings of
42    /// severity Medium or above.
43    MultiMedium {
44        min_findings: u32,
45        action: EscalationAction,
46    },
47}
48
49/// The action an escalation rule can upgrade to. Only Block is supported —
50/// escalation can never downgrade the action.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum EscalationAction {
54    Block,
55}
56
57impl EscalationAction {
58    fn to_action(self) -> Action {
59        match self {
60            EscalationAction::Block => Action::Block,
61        }
62    }
63}
64
65/// Captures exactly which rule/domain triggered an escalation, enabling
66/// correct per-key cooldown scoping.
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub struct EscalationHit {
69    /// The finding rule that crossed the threshold, or `"*"` for wildcard aggregate.
70    pub rule_id: String,
71    /// For `domain_scoped` rules, the specific domain that crossed the threshold.
72    pub domain: Option<String>,
73}
74
75impl EscalationHit {
76    /// True if this hit came from a wildcard aggregate threshold (`rule_id == "*"`).
77    pub fn is_wildcard(&self) -> bool {
78        self.rule_id == "*"
79    }
80}
81
82/// Apply escalation rules against session history + current findings.
83///
84/// Returns the (potentially upgraded) action, the set of causal rule_ids,
85/// structured escalation hits (for cooldown recording), and an optional
86/// human-readable reason string. Never downgrades the action.
87pub fn apply_escalation(
88    current_action: Action,
89    findings: &[Finding],
90    session: &SessionWarnings,
91    rules: &[EscalationRule],
92) -> (Action, HashSet<String>, Vec<EscalationHit>, Option<String>) {
93    let mut action = current_action;
94    let mut causal = HashSet::new();
95    let mut hits: Vec<EscalationHit> = Vec::new();
96    let mut reason: Option<String> = None;
97
98    for rule in rules {
99        match rule {
100            EscalationRule::RepeatCount {
101                rule_ids,
102                threshold,
103                window_minutes,
104                action: esc_action,
105                domain_scoped,
106                cooldown_minutes,
107            } => {
108                let target = esc_action.to_action();
109                if action_gte(action, target) {
110                    continue;
111                }
112
113                let wildcard = rule_ids.iter().any(|id| id == "*");
114
115                // Count current findings once per (rule_id, Option<domain>) key
116                // so we don't evaluate the same pair twice in the loop below.
117                let current_counts: HashMap<(String, Option<String>), u32> = {
118                    let mut map = HashMap::new();
119                    for f in findings {
120                        let rid = f.rule_id.to_string();
121                        if !wildcard && !rule_ids.iter().any(|id| id == &rid) {
122                            continue;
123                        }
124                        if *domain_scoped {
125                            let domains = extract_finding_domains(f);
126                            if domains.is_empty() {
127                                *map.entry((rid, None)).or_insert(0) += 1;
128                            } else {
129                                for d in domains {
130                                    *map.entry((rid.clone(), Some(d))).or_insert(0) += 1;
131                                }
132                            }
133                        } else {
134                            *map.entry((rid, None)).or_insert(0) += 1;
135                        }
136                    }
137                    map
138                };
139
140                for ((fid, domain), current_count) in &current_counts {
141                    if action_gte(action, target) {
142                        break;
143                    }
144
145                    if *cooldown_minutes > 0 {
146                        let cooldown_active = session.escalation_events.iter().any(|ev| {
147                            let rule_matches = if ev.rule_id == "*" {
148                                // Wildcard events only cool down the wildcard aggregate path.
149                                false
150                            } else {
151                                ev.rule_id == *fid
152                            };
153                            let domain_matches = match (&ev.domain, domain) {
154                                (Some(ed), Some(fd)) => ed == fd,
155                                (None, None) => true,
156                                _ => false,
157                            };
158                            rule_matches
159                                && domain_matches
160                                && is_within_minutes(&ev.timestamp, *cooldown_minutes)
161                        });
162                        if cooldown_active {
163                            continue;
164                        }
165                    }
166
167                    let session_count = if *domain_scoped {
168                        match domain {
169                            Some(d) => session.count_by_rule_and_domain(fid, d, *window_minutes),
170                            None => session.count_by_rule(fid, *window_minutes),
171                        }
172                    } else {
173                        session.count_by_rule(fid, *window_minutes)
174                    };
175
176                    let total = session_count + current_count;
177                    if total >= *threshold {
178                        action = target;
179                        causal.insert(fid.clone());
180                        hits.push(EscalationHit {
181                            rule_id: fid.clone(),
182                            domain: domain.clone(),
183                        });
184                        // Record a wildcard hit alongside so the aggregate
185                        // path cannot bypass per-rule cooldown.
186                        if wildcard {
187                            hits.push(EscalationHit {
188                                rule_id: "*".to_string(),
189                                domain: None,
190                            });
191                        }
192                        if reason.is_none() {
193                            reason = Some(format!(
194                                "{fid} triggered {total} times in {window_minutes}m (threshold: {threshold})"
195                            ));
196                        }
197                    }
198                }
199
200                // Wildcard aggregate path: catches mixed-rule sessions where no
201                // single rule crosses the threshold but the combined count does.
202                // Only applies when NOT domain_scoped.
203                if wildcard && !domain_scoped && !action_gte(action, target) {
204                    if *cooldown_minutes > 0 {
205                        let wildcard_cooled = session.escalation_events.iter().any(|ev| {
206                            ev.rule_id == "*" && is_within_minutes(&ev.timestamp, *cooldown_minutes)
207                        });
208                        if wildcard_cooled {
209                            continue;
210                        }
211                    }
212
213                    let total = session.count_all(*window_minutes) + findings.len() as u32;
214                    if total >= *threshold {
215                        action = target;
216                        for f in findings {
217                            causal.insert(f.rule_id.to_string());
218                        }
219                        hits.push(EscalationHit {
220                            rule_id: "*".to_string(),
221                            domain: None,
222                        });
223                        if reason.is_none() {
224                            reason = Some(format!(
225                                "{total} warnings in {window_minutes}m (threshold: {threshold})"
226                            ));
227                        }
228                    }
229                }
230            }
231            EscalationRule::MultiMedium {
232                min_findings,
233                action: esc_action,
234            } => {
235                let target = esc_action.to_action();
236                if action_gte(action, target) {
237                    continue;
238                }
239                let med_plus_count = findings
240                    .iter()
241                    .filter(|f| f.severity >= Severity::Medium)
242                    .count() as u32;
243                if med_plus_count >= *min_findings {
244                    action = target;
245                    for f in findings.iter().filter(|f| f.severity >= Severity::Medium) {
246                        causal.insert(f.rule_id.to_string());
247                    }
248                    if reason.is_none() {
249                        reason = Some(format!(
250                            "{med_plus_count} medium+ findings on one command (threshold: {min_findings})"
251                        ));
252                    }
253                }
254            }
255        }
256    }
257
258    let mut seen = HashSet::new();
259    hits.retain(|h| seen.insert((h.rule_id.clone(), h.domain.clone())));
260
261    (action, causal, hits, reason)
262}
263
264/// Check whether a timestamp string (RFC 3339) is within `minutes` of now.
265///
266/// Fail-safe: unparseable timestamps are treated as within-window so cooldown
267/// stays active rather than allowing premature re-escalation.
268fn is_within_minutes(timestamp: &str, minutes: u64) -> bool {
269    let Ok(ts) = chrono::DateTime::parse_from_rfc3339(timestamp) else {
270        return true;
271    };
272    let cutoff =
273        chrono::Utc::now() - chrono::Duration::minutes(minutes.min(u32::MAX as u64) as i64);
274    ts >= cutoff
275}
276
277/// Apply per-rule action overrides. Only "block" is a valid override value.
278/// Returns upgraded action and causal rule_ids.
279pub fn apply_action_overrides(
280    current_action: Action,
281    findings: &[Finding],
282    overrides: &HashMap<String, String>,
283) -> (Action, HashSet<String>) {
284    let mut action = current_action;
285    let mut causal = HashSet::new();
286
287    for finding in findings {
288        let fid = finding.rule_id.to_string();
289        if let Some(override_action) = overrides.get(&fid) {
290            if override_action == "block" && !action_gte(action, Action::Block) {
291                action = Action::Block;
292                causal.insert(fid);
293            }
294        }
295    }
296
297    (action, causal)
298}
299
300/// True if `a` is at least as strict as `b`.
301fn action_gte(a: Action, b: Action) -> bool {
302    action_rank(a) >= action_rank(b)
303}
304
305fn action_rank(a: Action) -> u8 {
306    match a {
307        Action::Allow => 0,
308        Action::Warn => 1,
309        Action::WarnAck => 2,
310        Action::Block => 3,
311    }
312}
313
314/// Extract domains from a single finding's evidence.
315fn extract_finding_domains(finding: &Finding) -> Vec<String> {
316    crate::session_warnings::extract_domains_from_evidence(&finding.evidence)
317}
318
319/// Where the verdict is being processed. Non-CLI callers cannot prompt
320/// interactively, so approval requirements become blocks.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum CallerContext {
323    Cli,
324    Gateway,
325    McpServer,
326    Daemon,
327}
328
329/// Shared post-processing pipeline applied after the core engine produces a
330/// raw verdict. Applies action overrides, approvals, paranoia filtering,
331/// escalation, and session warning recording in that order.
332///
333/// Side effects: reads and writes session state files (best-effort, never panics).
334pub fn post_process_verdict(
335    raw_verdict: &Verdict,
336    policy: &crate::policy::Policy,
337    cmd: &str,
338    session_id: &str,
339    caller: CallerContext,
340) -> Verdict {
341    let mut effective = raw_verdict.clone();
342    let mut causal_rule_ids: HashSet<String> = HashSet::new();
343
344    // Action overrides apply to the RAW findings — before paranoia or any
345    // other filter could remove them.
346    if !policy.action_overrides.is_empty() {
347        let (new_action, caused_by) = apply_action_overrides(
348            effective.action,
349            &effective.findings,
350            &policy.action_overrides,
351        );
352        effective.action = new_action;
353        causal_rule_ids.extend(caused_by);
354    }
355
356    // Approval detection must run before session warning recording so an
357    // approval-required verdict doesn't get booked as a vanilla warning.
358    if let Some(meta) = crate::approval::check_approval(&effective, policy) {
359        crate::approval::apply_approval(&mut effective, &meta);
360        causal_rule_ids.insert(meta.rule_id.clone());
361        if caller != CallerContext::Cli && effective.requires_approval == Some(true) {
362            effective.action = Action::Block;
363        }
364    }
365
366    // Save the pre-paranoia action so we can enforce "never downgrade" after
367    // filter_findings_by_paranoia recalculates the action from what's left.
368    let pre_paranoia_action = effective.action;
369
370    let causal_indices: Vec<usize> = raw_verdict
371        .findings
372        .iter()
373        .enumerate()
374        .filter(|(_, f)| causal_rule_ids.contains(&f.rule_id.to_string()))
375        .map(|(i, _)| i)
376        .collect();
377
378    crate::engine::filter_findings_by_paranoia(&mut effective, policy.paranoia);
379
380    // Paranoia must never downgrade an action that was explicitly set by an
381    // override or approval. Engine-natural verdicts (no causal rules) ARE
382    // allowed to be downgraded — that's the point of paranoia.
383    if !causal_rule_ids.is_empty()
384        && action_rank(pre_paranoia_action) > action_rank(effective.action)
385    {
386        effective.action = pre_paranoia_action;
387    }
388
389    for &idx in &causal_indices {
390        if idx < raw_verdict.findings.len() {
391            let causal = &raw_verdict.findings[idx];
392            let already_present = effective.findings.iter().any(|ef| {
393                ef.rule_id == causal.rule_id
394                    && ef.severity == causal.severity
395                    && ef.title == causal.title
396                    && ef.description == causal.description
397            });
398            if !already_present {
399                effective.findings.push(causal.clone());
400            }
401        }
402    }
403
404    // When paranoia filtered every finding out but an override/approval still
405    // forced a non-Allow action, surface some findings so the user can see WHY
406    // the action was set. Skip this when causal_rule_ids is empty — then the
407    // Allow-equivalent recompute is correct.
408    if !causal_rule_ids.is_empty()
409        && effective.action != Action::Allow
410        && effective.findings.is_empty()
411    {
412        for f in &raw_verdict.findings {
413            if f.severity >= Severity::Low
414                && !effective.findings.iter().any(|ef| ef.rule_id == f.rule_id)
415            {
416                effective.findings.push(f.clone());
417            }
418        }
419    }
420
421    // Escalation runs BEFORE warning recording so the escalated action wins.
422    if !policy.escalation.is_empty() && matches!(effective.action, Action::Warn | Action::WarnAck) {
423        let session = crate::session_warnings::load(session_id);
424        let (new_action, caused_by, escalation_hits, reason) = apply_escalation(
425            effective.action,
426            &effective.findings,
427            &session,
428            &policy.escalation,
429        );
430        if new_action != effective.action {
431            effective.escalation_reason = reason;
432            // Escalations upgrade to Action::Block, which skips the Warn/WarnAck
433            // recording path below — so record the escalation events separately.
434            crate::session_warnings::record_escalation_event(session_id, &escalation_hits);
435        }
436        effective.action = new_action;
437        causal_rule_ids.extend(caused_by);
438    }
439
440    // Hidden findings = multiset diff of raw minus effective. Keep the actual
441    // Finding references so record_outcome can store full HiddenEvent details
442    // (exposed via `tirith warnings --hidden`).
443    let hidden_findings_vec: Vec<&Finding> = {
444        let mut effective_counts: HashMap<(String, String, String, String), u32> = HashMap::new();
445        for f in &effective.findings {
446            let key = (
447                f.rule_id.to_string(),
448                f.severity.to_string(),
449                f.title.clone(),
450                f.description.clone(),
451            );
452            *effective_counts.entry(key).or_insert(0) += 1;
453        }
454        let mut hidden = Vec::new();
455        for f in &raw_verdict.findings {
456            let key = (
457                f.rule_id.to_string(),
458                f.severity.to_string(),
459                f.title.clone(),
460                f.description.clone(),
461            );
462            match effective_counts.get_mut(&key) {
463                Some(count) if *count > 0 => {
464                    *count -= 1;
465                }
466                _ => {
467                    hidden.push(f);
468                }
469            }
470        }
471        hidden
472    };
473
474    if matches!(effective.action, Action::Warn | Action::WarnAck) || !hidden_findings_vec.is_empty()
475    {
476        let warn_findings: Vec<&Finding> =
477            if matches!(effective.action, Action::Warn | Action::WarnAck) {
478                effective
479                    .findings
480                    .iter()
481                    .filter(|f| f.severity >= Severity::Low)
482                    .collect()
483            } else {
484                vec![]
485            };
486        crate::session_warnings::record_outcome(
487            session_id,
488            &warn_findings,
489            &hidden_findings_vec,
490            cmd,
491            &policy.dlp_custom_patterns,
492        );
493    }
494
495    effective
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::verdict::{Evidence, Finding, RuleId, Severity, Timings};
502
503    fn make_finding(rule_id: RuleId, severity: Severity) -> Finding {
504        Finding {
505            rule_id,
506            severity,
507            title: format!("{rule_id:?} finding"),
508            description: "test description".to_string(),
509            evidence: vec![Evidence::Text {
510                detail: "test".to_string(),
511            }],
512            human_view: None,
513            agent_view: None,
514            mitre_id: None,
515            custom_rule_id: None,
516        }
517    }
518
519    fn empty_session() -> SessionWarnings {
520        SessionWarnings {
521            session_id: "test".to_string(),
522            session_start: chrono::Utc::now().to_rfc3339(),
523            total_warnings: 0,
524            hidden_findings: 0,
525            hidden_low: 0,
526            hidden_info: 0,
527            events: std::collections::VecDeque::new(),
528            escalation_events: std::collections::VecDeque::new(),
529            hidden_events: std::collections::VecDeque::new(),
530        }
531    }
532
533    fn session_with_history(rule_id: &str, count: u32) -> SessionWarnings {
534        let mut session = empty_session();
535        let now = chrono::Utc::now().to_rfc3339();
536        for _ in 0..count {
537            session
538                .events
539                .push_back(crate::session_warnings::WarningEvent {
540                    timestamp: now.clone(),
541                    rule_id: rule_id.to_string(),
542                    severity: "MEDIUM".to_string(),
543                    title: "test".to_string(),
544                    command_redacted: "cmd".to_string(),
545                    domains: vec![],
546                });
547        }
548        session.total_warnings = count;
549        session
550    }
551
552    #[test]
553    fn test_repeat_count_below_threshold() {
554        let session = session_with_history("non_ascii_hostname", 2);
555        let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
556        let rules = vec![EscalationRule::RepeatCount {
557            rule_ids: vec!["non_ascii_hostname".to_string()],
558            threshold: 5,
559            window_minutes: 60,
560            action: EscalationAction::Block,
561            domain_scoped: false,
562            cooldown_minutes: 0,
563        }];
564
565        let (action, causal, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
566        // 2 (history) + 1 (current) = 3 < 5.
567        assert_eq!(action, Action::Warn);
568        assert!(causal.is_empty());
569    }
570
571    #[test]
572    fn test_repeat_count_meets_threshold() {
573        let session = session_with_history("non_ascii_hostname", 4);
574        let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
575        let rules = vec![EscalationRule::RepeatCount {
576            rule_ids: vec!["non_ascii_hostname".to_string()],
577            threshold: 5,
578            window_minutes: 60,
579            action: EscalationAction::Block,
580            domain_scoped: false,
581            cooldown_minutes: 0,
582        }];
583
584        let (action, causal, hits, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
585        assert_eq!(action, Action::Block);
586        assert!(causal.contains("non_ascii_hostname"));
587        assert!(hits
588            .iter()
589            .any(|h| h.rule_id == "non_ascii_hostname" && !h.is_wildcard()));
590    }
591
592    #[test]
593    fn test_repeat_count_wildcard() {
594        let session = session_with_history("any_rule", 9);
595        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
596        let rules = vec![EscalationRule::RepeatCount {
597            rule_ids: vec!["*".to_string()],
598            threshold: 10,
599            window_minutes: 60,
600            action: EscalationAction::Block,
601            domain_scoped: false,
602            cooldown_minutes: 0,
603        }];
604
605        let (action, _, hits, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
606        // 9 + 1 = 10 — the aggregate wildcard path.
607        assert_eq!(action, Action::Block);
608        assert!(hits.iter().any(|h| h.rule_id == "*" && h.is_wildcard()));
609    }
610
611    #[test]
612    fn test_multi_medium_below_threshold() {
613        let session = empty_session();
614        let findings = vec![
615            make_finding(RuleId::NonAsciiHostname, Severity::Medium),
616            make_finding(RuleId::ShortenedUrl, Severity::Low),
617        ];
618        let rules = vec![EscalationRule::MultiMedium {
619            min_findings: 3,
620            action: EscalationAction::Block,
621        }];
622
623        let (action, _, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
624        // Only 1 finding is ≥ Medium, threshold is 3.
625        assert_eq!(action, Action::Warn);
626    }
627
628    #[test]
629    fn test_multi_medium_meets_threshold() {
630        let session = empty_session();
631        let findings = vec![
632            make_finding(RuleId::NonAsciiHostname, Severity::Medium),
633            make_finding(RuleId::ShortenedUrl, Severity::Medium),
634            make_finding(RuleId::PlainHttpToSink, Severity::High),
635        ];
636        let rules = vec![EscalationRule::MultiMedium {
637            min_findings: 3,
638            action: EscalationAction::Block,
639        }];
640
641        let (action, causal, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
642        assert_eq!(action, Action::Block);
643        assert_eq!(causal.len(), 3);
644    }
645
646    #[test]
647    fn test_escalation_never_downgrades() {
648        let session = empty_session();
649        let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
650        let rules = vec![EscalationRule::RepeatCount {
651            rule_ids: vec!["non_ascii_hostname".to_string()],
652            threshold: 999,
653            window_minutes: 60,
654            action: EscalationAction::Block,
655            domain_scoped: false,
656            cooldown_minutes: 0,
657        }];
658
659        // Already Block: stays Block even though the rule's threshold isn't met.
660        let (action, _, _, _) = apply_escalation(Action::Block, &findings, &session, &rules);
661        assert_eq!(action, Action::Block);
662    }
663
664    #[test]
665    fn test_cooldown_suppresses_escalation() {
666        let mut session = session_with_history("shortened_url", 4);
667        session
668            .escalation_events
669            .push_back(crate::session_warnings::EscalationEvent {
670                timestamp: chrono::Utc::now().to_rfc3339(),
671                rule_id: "shortened_url".to_string(),
672                domain: None,
673            });
674        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
675        let rules = vec![EscalationRule::RepeatCount {
676            rule_ids: vec!["shortened_url".to_string()],
677            threshold: 3,
678            window_minutes: 60,
679            action: EscalationAction::Block,
680            domain_scoped: false,
681            cooldown_minutes: 60,
682        }];
683
684        // 4 (history) + 1 (current) = 5 ≥ threshold, but cooldown is active.
685        let (action, causal, hits, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
686        assert_eq!(action, Action::Warn);
687        assert!(causal.is_empty());
688        assert!(hits.is_empty());
689    }
690
691    #[test]
692    fn test_cooldown_zero_does_not_suppress() {
693        // cooldown_minutes=0 disables cooldown entirely.
694        let mut session = session_with_history("shortened_url", 4);
695        session
696            .escalation_events
697            .push_back(crate::session_warnings::EscalationEvent {
698                timestamp: chrono::Utc::now().to_rfc3339(),
699                rule_id: "shortened_url".to_string(),
700                domain: None,
701            });
702        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
703        let rules = vec![EscalationRule::RepeatCount {
704            rule_ids: vec!["shortened_url".to_string()],
705            threshold: 3,
706            window_minutes: 60,
707            action: EscalationAction::Block,
708            domain_scoped: false,
709            cooldown_minutes: 0,
710        }];
711
712        let (action, _, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
713        assert_eq!(action, Action::Block);
714    }
715
716    #[test]
717    fn test_wildcard_cooldown_suppresses_aggregate() {
718        // A prior wildcard escalation event must cool down the aggregate path.
719        let mut session = session_with_history("any_rule", 9);
720        session
721            .escalation_events
722            .push_back(crate::session_warnings::EscalationEvent {
723                timestamp: chrono::Utc::now().to_rfc3339(),
724                rule_id: "*".to_string(),
725                domain: None,
726            });
727        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
728        let rules = vec![EscalationRule::RepeatCount {
729            rule_ids: vec!["*".to_string()],
730            threshold: 10,
731            window_minutes: 60,
732            action: EscalationAction::Block,
733            domain_scoped: false,
734            cooldown_minutes: 60,
735        }];
736
737        let (action, _, _, _) = apply_escalation(Action::Warn, &findings, &session, &rules);
738        // 9 + 1 = 10, but wildcard cooldown is active.
739        assert_eq!(action, Action::Warn);
740    }
741
742    #[test]
743    fn test_action_override_block() {
744        let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
745        let mut overrides = HashMap::new();
746        overrides.insert("non_ascii_hostname".to_string(), "block".to_string());
747
748        let (action, causal) = apply_action_overrides(Action::Warn, &findings, &overrides);
749        assert_eq!(action, Action::Block);
750        assert!(causal.contains("non_ascii_hostname"));
751    }
752
753    #[test]
754    fn test_action_override_invalid_value_ignored() {
755        let findings = vec![make_finding(RuleId::NonAsciiHostname, Severity::Medium)];
756        let mut overrides = HashMap::new();
757        // Only "block" is a valid override value.
758        overrides.insert("non_ascii_hostname".to_string(), "warn".to_string());
759
760        let (action, causal) = apply_action_overrides(Action::Warn, &findings, &overrides);
761        assert_eq!(action, Action::Warn);
762        assert!(causal.is_empty());
763    }
764
765    #[test]
766    fn test_action_override_no_match() {
767        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
768        let mut overrides = HashMap::new();
769        overrides.insert("non_ascii_hostname".to_string(), "block".to_string());
770
771        let (action, causal) = apply_action_overrides(Action::Warn, &findings, &overrides);
772        assert_eq!(action, Action::Warn);
773        assert!(causal.is_empty());
774    }
775
776    #[test]
777    fn test_post_process_noop_on_allow() {
778        let raw = Verdict {
779            action: Action::Allow,
780            findings: vec![],
781            tier_reached: 1,
782            bypass_requested: false,
783            bypass_honored: false,
784            bypass_available: false,
785            interactive_detected: false,
786            policy_path_used: None,
787            timings_ms: Timings::default(),
788            urls_extracted_count: None,
789            requires_approval: None,
790            approval_timeout_secs: None,
791            approval_fallback: None,
792            approval_rule: None,
793            approval_description: None,
794            escalation_reason: None,
795        };
796        let policy = crate::policy::Policy::default();
797        let result = post_process_verdict(
798            &raw,
799            &policy,
800            "echo hello",
801            "test-session",
802            CallerContext::Cli,
803        );
804        assert_eq!(result.action, Action::Allow);
805        assert!(result.findings.is_empty());
806    }
807
808    #[test]
809    fn test_post_process_action_override_upgrades() {
810        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
811        let raw = Verdict {
812            action: Action::Warn,
813            findings,
814            tier_reached: 3,
815            bypass_requested: false,
816            bypass_honored: false,
817            bypass_available: false,
818            interactive_detected: false,
819            policy_path_used: None,
820            timings_ms: Timings::default(),
821            urls_extracted_count: None,
822            requires_approval: None,
823            approval_timeout_secs: None,
824            approval_fallback: None,
825            approval_rule: None,
826            approval_description: None,
827            escalation_reason: None,
828        };
829
830        let mut policy = crate::policy::Policy::default();
831        policy
832            .action_overrides
833            .insert("shortened_url".to_string(), "block".to_string());
834
835        let result = post_process_verdict(
836            &raw,
837            &policy,
838            "bit.ly/foo",
839            "test-session",
840            CallerContext::Cli,
841        );
842        assert_eq!(result.action, Action::Block);
843    }
844
845    #[test]
846    fn test_post_process_ordering_override_before_escalation() {
847        // Override must fire first; escalation then sees action already at Block
848        // and becomes a no-op.
849        let findings = vec![make_finding(RuleId::ShortenedUrl, Severity::Medium)];
850        let raw = Verdict {
851            action: Action::Warn,
852            findings,
853            tier_reached: 3,
854            bypass_requested: false,
855            bypass_honored: false,
856            bypass_available: false,
857            interactive_detected: false,
858            policy_path_used: None,
859            timings_ms: Timings::default(),
860            urls_extracted_count: None,
861            requires_approval: None,
862            approval_timeout_secs: None,
863            approval_fallback: None,
864            approval_rule: None,
865            approval_description: None,
866            escalation_reason: None,
867        };
868
869        let mut policy = crate::policy::Policy::default();
870        policy
871            .action_overrides
872            .insert("shortened_url".to_string(), "block".to_string());
873        policy.escalation.push(EscalationRule::RepeatCount {
874            rule_ids: vec!["shortened_url".to_string()],
875            threshold: 999,
876            window_minutes: 60,
877            action: EscalationAction::Block,
878            domain_scoped: false,
879            cooldown_minutes: 0,
880        });
881
882        let result = post_process_verdict(
883            &raw,
884            &policy,
885            "bit.ly/foo",
886            "test-session",
887            CallerContext::Cli,
888        );
889        assert_eq!(result.action, Action::Block);
890    }
891
892    #[test]
893    fn test_escalation_rule_serde() {
894        let json = r#"{"trigger":"repeat_count","rule_ids":["*"],"threshold":5,"action":"block"}"#;
895        let rule: EscalationRule = serde_json::from_str(json).unwrap();
896        match rule {
897            EscalationRule::RepeatCount {
898                threshold,
899                window_minutes,
900                cooldown_minutes,
901                ..
902            } => {
903                assert_eq!(threshold, 5);
904                assert_eq!(window_minutes, 60);
905                assert_eq!(cooldown_minutes, 0);
906            }
907            _ => panic!("expected RepeatCount"),
908        }
909
910        let json_cd = r#"{"trigger":"repeat_count","rule_ids":["*"],"threshold":5,"action":"block","cooldown_minutes":10}"#;
911        let rule_cd: EscalationRule = serde_json::from_str(json_cd).unwrap();
912        match rule_cd {
913            EscalationRule::RepeatCount {
914                cooldown_minutes, ..
915            } => {
916                assert_eq!(cooldown_minutes, 10);
917            }
918            _ => panic!("expected RepeatCount"),
919        }
920
921        let json2 = r#"{"trigger":"multi_medium","min_findings":3,"action":"block"}"#;
922        let rule2: EscalationRule = serde_json::from_str(json2).unwrap();
923        match rule2 {
924            EscalationRule::MultiMedium { min_findings, .. } => {
925                assert_eq!(min_findings, 3);
926            }
927            _ => panic!("expected MultiMedium"),
928        }
929    }
930
931    #[test]
932    fn test_hidden_count_multiset_with_duplicates() {
933        // Regression guard: duplicate findings sharing all identity fields
934        // must count as two in the raw-vs-effective multiset diff, so one
935        // surviving finding leaves one in the hidden set (not zero).
936        let dup_finding = make_finding(RuleId::ShortenedUrl, Severity::Medium);
937        let low_finding = Finding {
938            severity: Severity::Low,
939            ..make_finding(RuleId::NonAsciiHostname, Severity::Low)
940        };
941
942        let raw = Verdict {
943            action: Action::Warn,
944            findings: vec![dup_finding.clone(), dup_finding.clone(), low_finding],
945            tier_reached: 3,
946            bypass_requested: false,
947            bypass_honored: false,
948            bypass_available: false,
949            interactive_detected: false,
950            policy_path_used: None,
951            timings_ms: Timings::default(),
952            urls_extracted_count: None,
953            requires_approval: None,
954            approval_timeout_secs: None,
955            approval_fallback: None,
956            approval_rule: None,
957            approval_description: None,
958            escalation_reason: None,
959        };
960
961        // Default Policy has paranoia=1, which keeps Medium+ and removes Low.
962        let policy = crate::policy::Policy::default();
963        let result = post_process_verdict(
964            &raw,
965            &policy,
966            "test cmd",
967            "test-session",
968            CallerContext::Cli,
969        );
970
971        assert_eq!(
972            result
973                .findings
974                .iter()
975                .filter(|f| f.rule_id == RuleId::ShortenedUrl)
976                .count(),
977            2
978        );
979    }
980}