Skip to main content

termivar_scanner/planner/
selection.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use termivar_core::EntityId;
4
5use crate::{
6    knowledge::{KnowledgeBase, KnowledgeSnapshot},
7    rules::ExpressionEvaluation,
8};
9
10use crate::planner::{
11    model::{
12        AttackAction, AttackPlan, ExcludedAction, ExclusionReason, PlanStep, PlannerWrite,
13        PlanningContext, ResolvedVerificationTarget,
14    },
15    policy::{ActionSuppressionContext, ScheduledActionAuthorizationError},
16    scoring::UtilityBreakdown,
17    PlannerError,
18};
19
20#[derive(Debug, Clone)]
21struct EligibleCandidate {
22    action: AttackAction,
23    confidence_hypothesis_id: String,
24    verification_target: ResolvedVerificationTarget,
25    requirements: ExpressionEvaluation,
26    utility: UtilityBreakdown,
27}
28
29type CandidateEligibility = Result<EligibleCandidate, ExclusionReason>;
30
31/// Deterministic utility planner for declarative attack actions.
32#[derive(Debug, Clone, Default)]
33pub struct AttackPlanner {
34    actions: BTreeMap<String, AttackAction>,
35}
36
37impl AttackPlanner {
38    /// Creates an empty planner.
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Registers an idempotent action definition.
44    pub fn register(&mut self, action: AttackAction) -> Result<PlannerWrite, PlannerError> {
45        if let Some(existing) = self.actions.get(action.id()) {
46            return if existing == &action {
47                Ok(PlannerWrite::Unchanged)
48            } else {
49                Err(PlannerError::ActionIdentityConflict {
50                    id: action.id().to_owned(),
51                })
52            };
53        }
54        self.actions.insert(action.id().to_owned(), action);
55        Ok(PlannerWrite::Inserted)
56    }
57
58    /// Returns a registered action definition by stable identity.
59    pub fn action(&self, action_id: &str) -> Option<&AttackAction> {
60        self.actions.get(action_id)
61    }
62
63    /// Returns the number of registered action identities.
64    pub fn len(&self) -> usize {
65        self.actions.len()
66    }
67
68    /// Returns whether no actions are registered.
69    pub fn is_empty(&self) -> bool {
70        self.actions.is_empty()
71    }
72
73    /// Produces a plan from one internally consistent knowledge snapshot.
74    pub fn plan(
75        &self,
76        knowledge: &KnowledgeBase,
77        subject: &EntityId,
78        context: PlanningContext,
79    ) -> Result<AttackPlan, PlannerError> {
80        let snapshot = knowledge.snapshot_for_subject(subject);
81        self.plan_snapshot(&snapshot, context)
82    }
83
84    /// Produces a plan while excluding actions suppressed by adaptive policy.
85    pub fn plan_with_suppressed(
86        &self,
87        knowledge: &KnowledgeBase,
88        subject: &EntityId,
89        context: PlanningContext,
90        suppressed_actions: &BTreeSet<String>,
91    ) -> Result<AttackPlan, PlannerError> {
92        let snapshot = knowledge.snapshot_for_subject(subject);
93        self.plan_snapshot_with_suppressed(&snapshot, context, suppressed_actions)
94    }
95
96    /// Produces a plan from an explicit immutable snapshot.
97    pub fn plan_snapshot(
98        &self,
99        snapshot: &KnowledgeSnapshot,
100        context: PlanningContext,
101    ) -> Result<AttackPlan, PlannerError> {
102        self.plan_snapshot_with_suppressed(snapshot, context, &BTreeSet::new())
103    }
104
105    /// Produces a plan from a snapshot and an explicit policy suppression set.
106    pub fn plan_snapshot_with_suppressed(
107        &self,
108        snapshot: &KnowledgeSnapshot,
109        context: PlanningContext,
110        suppressed_actions: &BTreeSet<String>,
111    ) -> Result<AttackPlan, PlannerError> {
112        self.plan_snapshot_with_action_suppressions(
113            snapshot,
114            context,
115            &ActionSuppressionContext::policy_only(suppressed_actions),
116        )
117    }
118
119    /// Produces a plan distinguishing policy suppression from defense suppression.
120    ///
121    /// A defense-suppressed action is excluded with
122    /// [`ExclusionReason::DefenseSuppressed`], never conflated with an adaptive
123    /// or operator [`ExclusionReason::PolicySuppressed`]. A defense-suppressed
124    /// action never becomes a plan step, so it never reaches an executor. The
125    /// defense set filters the policy-authorized baseline and cannot refill
126    /// budget with a candidate that baseline excluded.
127    pub fn plan_snapshot_with_defense_suppressed(
128        &self,
129        snapshot: &KnowledgeSnapshot,
130        context: PlanningContext,
131        policy_suppressed_actions: &BTreeSet<String>,
132        defense_suppressed_actions: &BTreeSet<String>,
133    ) -> Result<AttackPlan, PlannerError> {
134        self.plan_snapshot_with_action_suppressions(
135            snapshot,
136            context,
137            &ActionSuppressionContext::new(
138                policy_suppressed_actions.clone(),
139                defense_suppressed_actions.clone(),
140            ),
141        )
142    }
143
144    pub(crate) fn plan_snapshot_with_action_suppressions(
145        &self,
146        snapshot: &KnowledgeSnapshot,
147        context: PlanningContext,
148        suppressions: &ActionSuppressionContext,
149    ) -> Result<AttackPlan, PlannerError> {
150        self.plan_snapshot_with_action_suppressions_and_baseline(snapshot, context, suppressions)
151            .map(|(_, filtered)| filtered)
152    }
153
154    pub(crate) fn plan_snapshot_with_action_suppressions_and_baseline(
155        &self,
156        snapshot: &KnowledgeSnapshot,
157        context: PlanningContext,
158        suppressions: &ActionSuppressionContext,
159    ) -> Result<(AttackPlan, AttackPlan), PlannerError> {
160        let baseline = self.plan_snapshot_with_policy_suppressed(
161            snapshot,
162            context,
163            suppressions.policy_suppressed_actions(),
164        )?;
165        let filtered = baseline
166            .clone()
167            .into_defense_filtered(suppressions.defense_suppressed_actions());
168        Ok((baseline, filtered))
169    }
170
171    fn plan_snapshot_with_policy_suppressed(
172        &self,
173        snapshot: &KnowledgeSnapshot,
174        context: PlanningContext,
175        policy_suppressed_actions: &BTreeSet<String>,
176    ) -> Result<AttackPlan, PlannerError> {
177        self.validate_dependencies()?;
178
179        let mut eligible = BTreeMap::<String, EligibleCandidate>::new();
180        let mut exclusions = BTreeMap::<String, ExclusionReason>::new();
181        for action in self.actions.values() {
182            let suppression = policy_suppressed_actions
183                .contains(action.id())
184                .then_some(ExclusionReason::PolicySuppressed);
185            match evaluate_candidate(action, snapshot, context, suppression)? {
186                Ok(candidate) => {
187                    eligible.insert(action.id.clone(), candidate);
188                },
189                Err(reason) => {
190                    exclusions.insert(action.id.clone(), reason);
191                },
192            }
193        }
194
195        let mut ranked: Vec<String> = eligible.keys().cloned().collect();
196        ranked.sort_by(|left, right| {
197            eligible[right]
198                .utility
199                .score
200                .cmp(&eligible[left].utility.score)
201                .then_with(|| left.cmp(right))
202        });
203
204        let mut selected = BTreeSet::<String>::new();
205        let mut ordered = Vec::<String>::new();
206        let mut total_cost = 0_u64;
207        for action_id in ranked {
208            if selected.contains(&action_id) {
209                continue;
210            }
211            let mut closure = Vec::new();
212            let mut visiting = BTreeSet::new();
213            if let Some(unavailable) = build_eligible_closure(
214                &action_id,
215                &eligible,
216                &selected,
217                &mut visiting,
218                &mut closure,
219            ) {
220                exclusions.insert(
221                    action_id.clone(),
222                    ExclusionReason::DependencyUnavailable {
223                        prerequisite: unavailable,
224                    },
225                );
226                continue;
227            }
228            let required = closure.iter().fold(0_u64, |sum, id| {
229                sum + u64::from(eligible[id].action.cost.units())
230            });
231            let remaining = context.budget.saturating_sub(total_cost);
232            if required > remaining {
233                exclusions.insert(
234                    action_id,
235                    ExclusionReason::BudgetExceeded {
236                        required,
237                        remaining,
238                    },
239                );
240                continue;
241            }
242            for id in closure {
243                if selected.insert(id.clone()) {
244                    total_cost += u64::from(eligible[&id].action.cost.units());
245                    ordered.push(id);
246                }
247            }
248        }
249
250        let steps = ordered
251            .into_iter()
252            .enumerate()
253            .map(|(position, id)| plan_step(position, &eligible[&id]))
254            .collect();
255        let mut excluded = Vec::new();
256        for id in self.actions.keys().filter(|id| !selected.contains(*id)) {
257            let reason = exclusions
258                .remove(id)
259                .ok_or_else(|| PlannerError::IncompleteDecision {
260                    action_id: id.clone(),
261                })?;
262            excluded.push(ExcludedAction {
263                action_id: id.clone(),
264                reason,
265            });
266        }
267
268        Ok(AttackPlan {
269            subject: snapshot.subject().clone(),
270            context,
271            total_cost,
272            steps,
273            excluded,
274        })
275    }
276
277    /// Re-applies planner authority to one registered action before immediate
278    /// adaptive dispatch.
279    ///
280    /// Unlike normal planning this does not rank the action against unrelated
281    /// candidates. It does validate the complete registered graph, then applies
282    /// the same suppression, requirement, risk, confidence, verification-target,
283    /// and minimum-utility checks as [`Self::plan_snapshot_with_suppressed`]. A
284    /// direct adaptive dispatch cannot safely satisfy a prerequisite closure,
285    /// because the session does not preserve proof that those actions completed;
286    /// such actions therefore fail closed. The requested action's own cost must
287    /// fit the complete planning budget.
288    #[cfg(test)]
289    pub(crate) fn authorize_scheduled_action(
290        &self,
291        snapshot: &KnowledgeSnapshot,
292        context: PlanningContext,
293        policy_suppressed_actions: &BTreeSet<String>,
294        action_id: &str,
295    ) -> Result<PlanStep, ScheduledActionAuthorizationError> {
296        self.authorize_scheduled_action_with_context(
297            snapshot,
298            context,
299            &ActionSuppressionContext::policy_only(policy_suppressed_actions),
300            action_id,
301        )
302    }
303
304    pub(crate) fn authorize_scheduled_action_with_context(
305        &self,
306        snapshot: &KnowledgeSnapshot,
307        context: PlanningContext,
308        suppressions: &ActionSuppressionContext,
309        action_id: &str,
310    ) -> Result<PlanStep, ScheduledActionAuthorizationError> {
311        self.validate_dependencies()?;
312        let action = self.actions.get(action_id).ok_or_else(|| {
313            ScheduledActionAuthorizationError::Unregistered {
314                action_id: action_id.to_owned(),
315            }
316        })?;
317        let suppression = if suppressions
318            .defense_suppressed_actions()
319            .contains(action_id)
320        {
321            Some(ExclusionReason::DefenseSuppressed)
322        } else if suppressions.policy_suppressed_actions().contains(action_id) {
323            Some(ExclusionReason::PolicySuppressed)
324        } else {
325            None
326        };
327        let candidate = match evaluate_candidate(action, snapshot, context, suppression)? {
328            Ok(candidate) => candidate,
329            Err(reason) => {
330                return Err(ScheduledActionAuthorizationError::Excluded {
331                    action_id: action_id.to_owned(),
332                    reason,
333                })
334            },
335        };
336        if !candidate.action.prerequisites.is_empty() {
337            return Err(ScheduledActionAuthorizationError::HasPrerequisites {
338                action_id: action_id.to_owned(),
339            });
340        }
341        let required = u64::from(candidate.action.cost.units());
342        if required > context.budget {
343            return Err(ScheduledActionAuthorizationError::Excluded {
344                action_id: action_id.to_owned(),
345                reason: ExclusionReason::BudgetExceeded {
346                    required,
347                    remaining: context.budget,
348                },
349            });
350        }
351        Ok(plan_step(0, &candidate))
352    }
353
354    fn validate_dependencies(&self) -> Result<(), PlannerError> {
355        for action in self.actions.values() {
356            for prerequisite in &action.prerequisites {
357                if !self.actions.contains_key(prerequisite) {
358                    return Err(PlannerError::UnknownPrerequisite {
359                        action_id: action.id.clone(),
360                        prerequisite: prerequisite.clone(),
361                    });
362                }
363            }
364        }
365        let mut visiting = BTreeSet::new();
366        let mut visited = BTreeSet::new();
367        for action_id in self.actions.keys() {
368            visit_dependency(action_id, &self.actions, &mut visiting, &mut visited)?;
369        }
370        Ok(())
371    }
372}
373
374fn evaluate_candidate(
375    action: &AttackAction,
376    snapshot: &KnowledgeSnapshot,
377    context: PlanningContext,
378    suppression: Option<ExclusionReason>,
379) -> Result<CandidateEligibility, PlannerError> {
380    if let Some(reason) = suppression {
381        return Ok(Err(reason));
382    }
383    let requirements = action.requirements.evaluate(snapshot)?;
384    if !requirements.matched() {
385        return Ok(Err(ExclusionReason::RequirementsNotMet));
386    }
387    if action.risk > context.maximum_risk {
388        return Ok(Err(ExclusionReason::RiskLimitExceeded {
389            actual: action.risk,
390            maximum: context.maximum_risk,
391        }));
392    }
393    let Some(hypothesis) = action.confidence_source.select(snapshot.hypotheses()) else {
394        return Ok(Err(ExclusionReason::NoEligibleHypothesis));
395    };
396    let Some(verification_target) = action
397        .verification_target
398        .resolve(snapshot.hypotheses(), hypothesis.id())
399    else {
400        return Ok(Err(ExclusionReason::NoEligibleVerificationTarget));
401    };
402    let utility = UtilityBreakdown::calculate(
403        action.gain,
404        hypothesis.posterior(),
405        context.business_value,
406        action.cost,
407        action.risk,
408    );
409    if utility.score < context.minimum_utility {
410        return Ok(Err(ExclusionReason::BelowMinimumUtility {
411            actual: utility.score,
412            minimum: context.minimum_utility,
413        }));
414    }
415    Ok(Ok(EligibleCandidate {
416        action: action.clone(),
417        confidence_hypothesis_id: hypothesis.id().to_owned(),
418        verification_target,
419        requirements,
420        utility,
421    }))
422}
423
424fn plan_step(position: usize, candidate: &EligibleCandidate) -> PlanStep {
425    PlanStep {
426        position,
427        action_id: candidate.action.id.clone(),
428        executor: candidate.action.executor.clone(),
429        payload_strategy: candidate.action.payload_strategy.clone(),
430        prerequisites: candidate.action.prerequisites.clone(),
431        confidence_hypothesis_id: candidate.confidence_hypothesis_id.clone(),
432        verification_target: candidate.verification_target.clone(),
433        requirements: candidate.requirements.clone(),
434        utility: candidate.utility,
435    }
436}
437
438fn visit_dependency(
439    action_id: &str,
440    actions: &BTreeMap<String, AttackAction>,
441    visiting: &mut BTreeSet<String>,
442    visited: &mut BTreeSet<String>,
443) -> Result<(), PlannerError> {
444    if visited.contains(action_id) {
445        return Ok(());
446    }
447    if !visiting.insert(action_id.to_owned()) {
448        return Err(PlannerError::DependencyCycle {
449            action_id: action_id.to_owned(),
450        });
451    }
452    for prerequisite in &actions[action_id].prerequisites {
453        visit_dependency(prerequisite, actions, visiting, visited)?;
454    }
455    visiting.remove(action_id);
456    visited.insert(action_id.to_owned());
457    Ok(())
458}
459
460fn build_eligible_closure(
461    action_id: &str,
462    eligible: &BTreeMap<String, EligibleCandidate>,
463    selected: &BTreeSet<String>,
464    visiting: &mut BTreeSet<String>,
465    ordered: &mut Vec<String>,
466) -> Option<String> {
467    if selected.contains(action_id) || ordered.iter().any(|id| id == action_id) {
468        return None;
469    }
470    let Some(candidate) = eligible.get(action_id) else {
471        return Some(action_id.to_owned());
472    };
473    visiting.insert(action_id.to_owned());
474    for prerequisite in &candidate.action.prerequisites {
475        if !eligible.contains_key(prerequisite) {
476            return Some(prerequisite.clone());
477        }
478        if !visiting.contains(prerequisite) {
479            if let Some(unavailable) =
480                build_eligible_closure(prerequisite, eligible, selected, visiting, ordered)
481            {
482                return Some(unavailable);
483            }
484        }
485    }
486    visiting.remove(action_id);
487    ordered.push(action_id.to_owned());
488    None
489}