Skip to main content

termivar_scanner/
planner.rs

1//! Deterministic, budget-aware attack planning.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! The planner ranks declarative actions but never executes them. It consumes
13//! one immutable knowledge snapshot, evaluates action requirements, derives
14//! confidence from Bayesian hypotheses, and emits an explainable plan.
15
16use thiserror::Error;
17
18use crate::rules::RuleEngineError;
19
20#[cfg(test)]
21use std::collections::{BTreeMap, BTreeSet};
22#[cfg(test)]
23use termivar_core::{EntityId, EvidenceValue, Hypothesis, HypothesisStrength, Probability};
24
25#[cfg(test)]
26use crate::{knowledge::KnowledgeBase, payload_strategy::PayloadStrategyRef, rules::Expression};
27
28mod model;
29mod policy;
30mod scoring;
31mod selection;
32
33pub use model::{
34    AttackAction, AttackPlan, ExcludedAction, ExclusionReason, HypothesisSelector, PlanStep,
35    PlannerWrite, PlanningContext, RequiredStrength, ResolvedVerificationTarget,
36    VerificationTarget,
37};
38pub(crate) use policy::{ActionSuppressionContext, ScheduledActionAuthorizationError};
39pub use scoring::{ActionCost, BenefitScore, RiskScore, UtilityBreakdown, UtilityScore};
40pub use selection::AttackPlanner;
41
42/// Validation and consistency failures raised by the attack planner.
43#[derive(Debug, Error)]
44#[non_exhaustive]
45pub enum PlannerError {
46    /// A required identifier or executor name was empty.
47    #[error("{field} must not be empty")]
48    EmptyValue { field: &'static str },
49
50    /// A normalized benefit exceeded 10,000 basis points.
51    #[error("benefit score {0} exceeds 10,000 basis points")]
52    BenefitOutOfRange(u16),
53
54    /// Risk must be in the inclusive `1..=10_000` range.
55    #[error("risk score {0} must be between 1 and 10,000 basis points")]
56    RiskOutOfRange(u16),
57
58    /// Estimated action cost must be positive.
59    #[error("action cost must be greater than zero")]
60    ZeroCost,
61
62    /// An action listed itself as a prerequisite.
63    #[error("action {action_id} cannot depend on itself")]
64    SelfDependency { action_id: String },
65
66    /// An action referenced a prerequisite that is not registered.
67    #[error("action {action_id} references unknown prerequisite {prerequisite}")]
68    UnknownPrerequisite {
69        /// Action containing the reference.
70        action_id: String,
71        /// Missing action identity.
72        prerequisite: String,
73    },
74
75    /// The action dependency graph contains a cycle.
76    #[error("action dependency cycle includes {action_id}")]
77    DependencyCycle { action_id: String },
78
79    /// An action identity was reused with different semantics.
80    #[error("action identity {id} already has a different definition")]
81    ActionIdentityConflict { id: String },
82
83    /// Internal selection accounting omitted a registered action.
84    #[error("planner produced no selection decision for action {action_id}")]
85    IncompleteDecision { action_id: String },
86
87    /// Expression evaluation failed.
88    #[error(transparent)]
89    Rule(RuleEngineError),
90}
91
92impl From<RuleEngineError> for PlannerError {
93    fn from(source: RuleEngineError) -> Self {
94        Self::Rule(source)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::{
102        EvidenceCalibration, EvidenceSelector, ExperiencePolicy, ExperienceStore,
103        HypothesisConclusion, KnowledgeLayer, ReasoningRule, RuleEngine,
104    };
105    use termivar_core::{
106        BayesianEvidence, ConfidenceScore, Evidence, EvidenceKind, EvidenceSource, HypothesisState,
107        KnowledgePredicate, Outcome, OutcomeStatus, VerificationStage,
108    };
109
110    fn subject() -> EntityId {
111        EntityId::new("endpoint:https://example.test").unwrap()
112    }
113
114    fn stack_predicate() -> KnowledgePredicate {
115        KnowledgePredicate::new("stack", "framework").unwrap()
116    }
117
118    fn stack_value() -> EvidenceValue {
119        EvidenceValue::Text("Laravel".into())
120    }
121
122    fn knowledge_with_hypothesis(posterior_signal: (u8, u8)) -> KnowledgeBase {
123        let knowledge = KnowledgeBase::new();
124        let evidence = Evidence::new(
125            subject(),
126            EvidenceKind::Technology,
127            KnowledgePredicate::new("technology", "framework").unwrap(),
128            stack_value(),
129            EvidenceSource::new("discovery", "framework-header").unwrap(),
130            ConfidenceScore::from_percent(90).unwrap(),
131        );
132        knowledge.insert_evidence(evidence.clone()).unwrap();
133        let mut hypothesis = Hypothesis::with_id(
134            "hypothesis:laravel",
135            subject(),
136            stack_predicate(),
137            stack_value(),
138            Probability::from_percent(50).unwrap(),
139        )
140        .unwrap();
141        hypothesis
142            .observe(
143                BayesianEvidence::new(
144                    evidence.id().clone(),
145                    Probability::from_percent(posterior_signal.0).unwrap(),
146                    Probability::from_percent(posterior_signal.1).unwrap(),
147                    "framework fingerprint",
148                )
149                .unwrap(),
150            )
151            .unwrap();
152        hypothesis.set_strength(HypothesisStrength::Strong);
153        hypothesis.set_state(HypothesisState::Supported);
154        knowledge.upsert_hypothesis(hypothesis).unwrap();
155        knowledge
156    }
157
158    fn action(id: &str, gain: u8, cost: u32, risk: u8, prerequisites: &[&str]) -> AttackAction {
159        AttackAction::new(
160            id,
161            format!("plugin.{id}"),
162            Expression::equals(KnowledgeLayer::Hypothesis, stack_predicate(), stack_value()),
163            HypothesisSelector::new(
164                stack_predicate(),
165                stack_value(),
166                Probability::from_percent(50).unwrap(),
167                RequiredStrength::Strong,
168            ),
169            BenefitScore::from_percent(gain).unwrap(),
170            ActionCost::new(cost).unwrap(),
171            RiskScore::from_percent(risk).unwrap(),
172            prerequisites.iter().map(|value| (*value).into()).collect(),
173        )
174        .unwrap()
175    }
176
177    fn context(budget: u64) -> PlanningContext {
178        PlanningContext::new(
179            BenefitScore::from_percent(90).unwrap(),
180            budget,
181            RiskScore::from_percent(80).unwrap(),
182        )
183    }
184
185    #[test]
186    fn utility_uses_fixed_point_formula() {
187        let utility = UtilityBreakdown::calculate(
188            BenefitScore::from_percent(80).unwrap(),
189            Probability::from_percent(75).unwrap(),
190            BenefitScore::from_percent(90).unwrap(),
191            ActionCost::new(100).unwrap(),
192            RiskScore::from_percent(20).unwrap(),
193        );
194
195        assert_eq!(utility.score().units(), 270_000_000);
196        let encoded = serde_json::to_value(utility).unwrap();
197        assert_eq!(
198            serde_json::from_value::<UtilityBreakdown>(encoded.clone()).unwrap(),
199            utility
200        );
201        let mut tampered = encoded;
202        tampered["score"] = serde_json::json!(1);
203        assert!(serde_json::from_value::<UtilityBreakdown>(tampered).is_err());
204    }
205
206    #[test]
207    fn planner_orders_equal_utility_by_action_id() {
208        let knowledge = knowledge_with_hypothesis((80, 20));
209        let mut planner = AttackPlanner::new();
210        planner.register(action("zeta", 80, 10, 20, &[])).unwrap();
211        planner.register(action("alpha", 80, 10, 20, &[])).unwrap();
212
213        let first = planner.plan(&knowledge, &subject(), context(100)).unwrap();
214        let second = planner.plan(&knowledge, &subject(), context(100)).unwrap();
215
216        assert_eq!(first, second);
217        assert_eq!(first.steps()[0].action_id(), "alpha");
218        assert_eq!(first.steps()[1].action_id(), "zeta");
219        assert_eq!(first.total_cost(), 20);
220    }
221
222    #[test]
223    fn registration_order_cannot_change_dependency_or_suppression_semantics() {
224        let knowledge = knowledge_with_hypothesis((80, 20));
225        let actions = [
226            action("root", 60, 10, 20, &[]),
227            action("dependent", 90, 10, 20, &["root"]),
228            action("independent", 70, 10, 20, &[]),
229            action("knowledge-only", 50, 10, 20, &[])
230                .with_verification_target(VerificationTarget::KnowledgeOnly),
231        ];
232        let mut forward = AttackPlanner::new();
233        let mut reverse = AttackPlanner::new();
234        for action in &actions {
235            forward.register(action.clone()).unwrap();
236        }
237        for action in actions.iter().rev() {
238            reverse.register(action.clone()).unwrap();
239        }
240        let suppressions = BTreeSet::from(["independent".to_owned()]);
241
242        let forward_plan = forward
243            .plan_with_suppressed(&knowledge, &subject(), context(100), &suppressions)
244            .unwrap();
245        let reverse_plan = reverse
246            .plan_with_suppressed(&knowledge, &subject(), context(100), &suppressions)
247            .unwrap();
248
249        assert_eq!(forward_plan, reverse_plan);
250        let positions: BTreeMap<_, _> = forward_plan
251            .steps()
252            .iter()
253            .map(|step| (step.action_id(), step.position()))
254            .collect();
255        assert!(positions["root"] < positions["dependent"]);
256        assert!(!positions.contains_key("independent"));
257        assert_eq!(
258            forward_plan
259                .excluded()
260                .iter()
261                .find(|excluded| excluded.action_id() == "independent")
262                .unwrap()
263                .reason(),
264            &ExclusionReason::PolicySuppressed
265        );
266    }
267
268    #[test]
269    fn defense_filter_is_exact_baseline_subsequence_without_budget_refill() {
270        let knowledge = knowledge_with_hypothesis((80, 20));
271        let snapshot = knowledge.snapshot_for_subject(&subject());
272        let mut planner = AttackPlanner::new();
273        for id in ["alpha", "beta", "gamma"] {
274            planner.register(action(id, 80, 10, 20, &[])).unwrap();
275        }
276        let baseline = planner.plan_snapshot(&snapshot, context(20)).unwrap();
277        assert_eq!(
278            baseline
279                .steps()
280                .iter()
281                .map(PlanStep::action_id)
282                .collect::<Vec<_>>(),
283            ["alpha", "beta"]
284        );
285        assert_eq!(baseline.excluded()[0].action_id(), "gamma");
286        let expected_beta = baseline.steps()[1].clone();
287
288        let budget_only_suppression = baseline
289            .clone()
290            .into_defense_filtered(&BTreeSet::from(["gamma".to_owned()]));
291        assert_eq!(budget_only_suppression, baseline);
292        assert_eq!(
293            serde_json::to_vec(&budget_only_suppression).unwrap(),
294            serde_json::to_vec(&baseline).unwrap()
295        );
296
297        let suppressions =
298            ActionSuppressionContext::new(BTreeSet::new(), BTreeSet::from(["alpha".to_owned()]));
299        let filtered = planner
300            .plan_snapshot_with_action_suppressions(&snapshot, context(20), &suppressions)
301            .unwrap();
302
303        assert_eq!(
304            filtered
305                .steps()
306                .iter()
307                .map(PlanStep::action_id)
308                .collect::<Vec<_>>(),
309            ["beta"]
310        );
311        assert_eq!(filtered.total_cost(), 10);
312        assert_eq!(filtered.steps()[0].position(), 0);
313        assert_eq!(filtered.steps()[0].executor(), expected_beta.executor());
314        assert_eq!(filtered.steps()[0].utility(), expected_beta.utility());
315        assert_eq!(
316            filtered.steps()[0].requirements(),
317            expected_beta.requirements()
318        );
319        assert!(filtered
320            .excluded()
321            .iter()
322            .any(|entry| entry.action_id() == "alpha"
323                && entry.reason() == &ExclusionReason::DefenseSuppressed));
324        assert!(filtered
325            .excluded()
326            .iter()
327            .any(|entry| entry.action_id() == "gamma"
328                && matches!(entry.reason(), ExclusionReason::BudgetExceeded { .. })));
329    }
330
331    #[test]
332    fn defense_filter_cascades_dependencies_and_preserves_retained_metadata() {
333        let knowledge = knowledge_with_hypothesis((80, 20));
334        let snapshot = knowledge.snapshot_for_subject(&subject());
335        let strategy = PayloadStrategyRef::new("independent.strategy", 4).unwrap();
336        let mut planner = AttackPlanner::new();
337        planner.register(action("root", 80, 10, 20, &[])).unwrap();
338        planner
339            .register(action("dependent", 90, 10, 20, &["root"]))
340            .unwrap();
341        planner
342            .register(
343                action("independent", 70, 10, 20, &[]).with_payload_strategy(strategy.clone()),
344            )
345            .unwrap();
346        let baseline = planner.plan_snapshot(&snapshot, context(100)).unwrap();
347        let expected = baseline
348            .steps()
349            .iter()
350            .find(|step| step.action_id() == "independent")
351            .unwrap()
352            .clone();
353
354        let filtered = baseline
355            .clone()
356            .into_defense_filtered(&BTreeSet::from(["root".to_owned()]));
357
358        assert_eq!(
359            filtered
360                .steps()
361                .iter()
362                .map(PlanStep::action_id)
363                .collect::<Vec<_>>(),
364            ["independent"]
365        );
366        let retained = &filtered.steps()[0];
367        assert_eq!(retained.executor(), expected.executor());
368        assert_eq!(retained.payload_strategy(), Some(&strategy));
369        assert_eq!(retained.prerequisites(), expected.prerequisites());
370        assert_eq!(
371            retained.confidence_hypothesis_id(),
372            expected.confidence_hypothesis_id()
373        );
374        assert_eq!(
375            retained.verification_target(),
376            expected.verification_target()
377        );
378        assert_eq!(retained.requirements(), expected.requirements());
379        assert_eq!(retained.utility(), expected.utility());
380        assert!(filtered.excluded().iter().any(|entry| {
381            entry.action_id() == "dependent"
382                && entry.reason()
383                    == &ExclusionReason::DependencyUnavailable {
384                        prerequisite: "root".to_owned(),
385                    }
386        }));
387    }
388
389    #[test]
390    fn empty_defense_context_is_exact_planner_compatibility_path() {
391        let knowledge = knowledge_with_hypothesis((80, 20));
392        let snapshot = knowledge.snapshot_for_subject(&subject());
393        let mut planner = AttackPlanner::new();
394        planner.register(action("alpha", 80, 10, 20, &[])).unwrap();
395
396        let baseline = planner.plan_snapshot(&snapshot, context(100)).unwrap();
397        let compatibility = planner
398            .plan_snapshot_with_action_suppressions(
399                &snapshot,
400                context(100),
401                &ActionSuppressionContext::default(),
402            )
403            .unwrap();
404
405        assert_eq!(compatibility, baseline);
406        assert_eq!(
407            serde_json::to_vec(&compatibility).unwrap(),
408            serde_json::to_vec(&baseline).unwrap()
409        );
410    }
411
412    #[test]
413    fn defense_precedes_policy_for_scheduled_authorization() {
414        let knowledge = knowledge_with_hypothesis((80, 20));
415        let snapshot = knowledge.snapshot_for_subject(&subject());
416        let mut planner = AttackPlanner::new();
417        planner.register(action("direct", 80, 10, 20, &[])).unwrap();
418        let both = BTreeSet::from(["direct".to_owned()]);
419
420        let policy_baseline = planner
421            .plan_snapshot_with_defense_suppressed(&snapshot, context(100), &both, &BTreeSet::new())
422            .unwrap();
423        let defense_filtered = planner
424            .plan_snapshot_with_defense_suppressed(&snapshot, context(100), &both, &both)
425            .unwrap();
426        assert_eq!(defense_filtered, policy_baseline);
427        assert_eq!(
428            serde_json::to_vec(&defense_filtered).unwrap(),
429            serde_json::to_vec(&policy_baseline).unwrap()
430        );
431        assert_eq!(
432            defense_filtered.excluded()[0].reason(),
433            &ExclusionReason::PolicySuppressed
434        );
435
436        let denied = planner
437            .authorize_scheduled_action_with_context(
438                &snapshot,
439                context(100),
440                &ActionSuppressionContext::new(both.clone(), both),
441                "direct",
442            )
443            .unwrap_err();
444
445        assert!(matches!(
446            denied,
447            ScheduledActionAuthorizationError::Excluded {
448                reason: ExclusionReason::DefenseSuppressed,
449                ..
450            }
451        ));
452    }
453
454    #[test]
455    fn scheduled_action_authorization_reuses_exact_planner_eligibility_and_policy() {
456        let knowledge = knowledge_with_hypothesis((80, 20));
457        let selected = action("direct", 80, 10, 20, &[])
458            .with_payload_strategy(PayloadStrategyRef::new("direct.strategy", 2).unwrap())
459            .with_verification_target(VerificationTarget::KnowledgeOnly);
460        let mut planner = AttackPlanner::new();
461        planner.register(selected).unwrap();
462        let snapshot = knowledge.snapshot_for_subject(&subject());
463        let exact_context = PlanningContext::new(
464            BenefitScore::from_percent(90).unwrap(),
465            10,
466            RiskScore::from_percent(20).unwrap(),
467        );
468
469        let planned = planner.plan_snapshot(&snapshot, exact_context).unwrap();
470        let authorized = planner
471            .authorize_scheduled_action(&snapshot, exact_context, &BTreeSet::new(), "direct")
472            .unwrap();
473
474        assert_eq!(&authorized, &planned.steps()[0]);
475        assert_eq!(authorized.executor(), "plugin.direct");
476        assert_eq!(
477            authorized.payload_strategy(),
478            Some(&PayloadStrategyRef::new("direct.strategy", 2).unwrap())
479        );
480        assert_eq!(
481            authorized.verification_target(),
482            &ResolvedVerificationTarget::KnowledgeOnly
483        );
484        assert!(!authorized
485            .verification_target()
486            .applies_hypothesis_transition());
487    }
488
489    #[test]
490    fn minimum_utility_exact_boundary_remains_eligible() {
491        let knowledge = knowledge_with_hypothesis((80, 20));
492        let mut planner = AttackPlanner::new();
493        planner.register(action("direct", 80, 10, 20, &[])).unwrap();
494        let snapshot = knowledge.snapshot_for_subject(&subject());
495        let base_context = context(100);
496        let score = planner
497            .authorize_scheduled_action(&snapshot, base_context, &BTreeSet::new(), "direct")
498            .unwrap()
499            .utility()
500            .score();
501
502        planner
503            .authorize_scheduled_action(
504                &snapshot,
505                base_context.with_minimum_utility(score),
506                &BTreeSet::new(),
507                "direct",
508            )
509            .unwrap();
510        assert!(matches!(
511            planner.authorize_scheduled_action(
512                &snapshot,
513                base_context.with_minimum_utility(UtilityScore::from_units(
514                    score.units().checked_add(1).unwrap(),
515                )),
516                &BTreeSet::new(),
517                "direct",
518            ),
519            Err(ScheduledActionAuthorizationError::Excluded {
520                reason: ExclusionReason::BelowMinimumUtility { .. },
521                ..
522            })
523        ));
524    }
525
526    #[test]
527    fn scheduled_action_authorization_enforces_suppression_budget_and_risk_boundaries() {
528        let knowledge = knowledge_with_hypothesis((80, 20));
529        let mut planner = AttackPlanner::new();
530        planner.register(action("direct", 80, 10, 20, &[])).unwrap();
531        let snapshot = knowledge.snapshot_for_subject(&subject());
532        let exact_context = PlanningContext::new(
533            BenefitScore::from_percent(90).unwrap(),
534            10,
535            RiskScore::from_percent(20).unwrap(),
536        );
537
538        planner
539            .authorize_scheduled_action(&snapshot, exact_context, &BTreeSet::new(), "direct")
540            .unwrap();
541        let suppressed = planner
542            .authorize_scheduled_action(
543                &snapshot,
544                exact_context,
545                &BTreeSet::from(["direct".to_owned()]),
546                "direct",
547            )
548            .unwrap_err();
549        assert!(matches!(
550            suppressed,
551            ScheduledActionAuthorizationError::Excluded {
552                action_id,
553                reason: ExclusionReason::PolicySuppressed,
554            } if action_id == "direct"
555        ));
556
557        let budget = planner
558            .authorize_scheduled_action(
559                &snapshot,
560                PlanningContext::new(
561                    BenefitScore::from_percent(90).unwrap(),
562                    9,
563                    RiskScore::from_percent(20).unwrap(),
564                ),
565                &BTreeSet::new(),
566                "direct",
567            )
568            .unwrap_err();
569        assert!(matches!(
570            budget,
571            ScheduledActionAuthorizationError::Excluded {
572                action_id,
573                reason: ExclusionReason::BudgetExceeded {
574                    required: 10,
575                    remaining: 9,
576                },
577            } if action_id == "direct"
578        ));
579
580        let risk = planner
581            .authorize_scheduled_action(
582                &snapshot,
583                PlanningContext::new(
584                    BenefitScore::from_percent(90).unwrap(),
585                    10,
586                    RiskScore::from_percent(19).unwrap(),
587                ),
588                &BTreeSet::new(),
589                "direct",
590            )
591            .unwrap_err();
592        assert!(matches!(
593            risk,
594            ScheduledActionAuthorizationError::Excluded {
595                action_id,
596                reason: ExclusionReason::RiskLimitExceeded { .. },
597            } if action_id == "direct"
598        ));
599    }
600
601    #[test]
602    fn scheduled_action_authorization_fails_closed_on_registry_and_dependency_graphs() {
603        let knowledge = knowledge_with_hypothesis((80, 20));
604        let snapshot = knowledge.snapshot_for_subject(&subject());
605        let mut planner = AttackPlanner::new();
606        planner.register(action("base", 40, 5, 10, &[])).unwrap();
607        planner
608            .register(action("dependent", 80, 5, 10, &["base"]))
609            .unwrap();
610
611        assert!(matches!(
612            planner.authorize_scheduled_action(
613                &snapshot,
614                context(10),
615                &BTreeSet::new(),
616                "unknown",
617            ),
618            Err(ScheduledActionAuthorizationError::Unregistered { action_id })
619                if action_id == "unknown"
620        ));
621        assert!(matches!(
622            planner.authorize_scheduled_action(
623                &snapshot,
624                context(10),
625                &BTreeSet::new(),
626                "dependent",
627            ),
628            Err(ScheduledActionAuthorizationError::HasPrerequisites { action_id })
629                if action_id == "dependent"
630        ));
631        assert!(matches!(
632            planner.authorize_scheduled_action(
633                &snapshot,
634                context(10),
635                &BTreeSet::from(["dependent".to_owned()]),
636                "dependent",
637            ),
638            Err(ScheduledActionAuthorizationError::Excluded {
639                action_id,
640                reason: ExclusionReason::PolicySuppressed,
641            }) if action_id == "dependent"
642        ));
643
644        let mut invalid = AttackPlanner::new();
645        invalid
646            .register(action("invalid", 80, 5, 10, &["missing"]))
647            .unwrap();
648        invalid.register(action("direct", 80, 5, 10, &[])).unwrap();
649        assert!(matches!(
650            invalid.authorize_scheduled_action(&snapshot, context(10), &BTreeSet::new(), "direct",),
651            Err(ScheduledActionAuthorizationError::Planner(
652                PlannerError::UnknownPrerequisite { .. }
653            ))
654        ));
655    }
656
657    #[test]
658    fn scheduled_action_authorization_reuses_requirement_target_and_utility_checks() {
659        let knowledge = knowledge_with_hypothesis((80, 20));
660        let snapshot = knowledge.snapshot_for_subject(&subject());
661        let unmet = AttackAction::new(
662            "unmet",
663            "plugin.unmet",
664            Expression::exists(
665                KnowledgeLayer::Evidence,
666                KnowledgePredicate::new("authentication", "mfa").unwrap(),
667            ),
668            HypothesisSelector::new(
669                stack_predicate(),
670                stack_value(),
671                Probability::from_percent(50).unwrap(),
672                RequiredStrength::Strong,
673            ),
674            BenefitScore::from_percent(80).unwrap(),
675            ActionCost::new(10).unwrap(),
676            RiskScore::from_percent(10).unwrap(),
677            BTreeSet::new(),
678        )
679        .unwrap();
680        let missing_motivation = AttackAction::new(
681            "missing-motivation",
682            "plugin.missing-motivation",
683            Expression::equals(KnowledgeLayer::Hypothesis, stack_predicate(), stack_value()),
684            HypothesisSelector::new(
685                KnowledgePredicate::new("auth", "missing-motivation").unwrap(),
686                EvidenceValue::Boolean(true),
687                Probability::from_percent(50).unwrap(),
688                RequiredStrength::Any,
689            ),
690            BenefitScore::from_percent(80).unwrap(),
691            ActionCost::new(10).unwrap(),
692            RiskScore::from_percent(10).unwrap(),
693            BTreeSet::new(),
694        )
695        .unwrap();
696        let missing_target = action("missing-target", 80, 10, 10, &[]).with_verification_target(
697            VerificationTarget::Distinct(HypothesisSelector::new(
698                KnowledgePredicate::new("auth", "mechanism").unwrap(),
699                EvidenceValue::Text("missing".to_owned()),
700                Probability::from_percent(50).unwrap(),
701                RequiredStrength::Any,
702            )),
703        );
704        let mut planner = AttackPlanner::new();
705        planner.register(unmet).unwrap();
706        planner.register(missing_motivation).unwrap();
707        planner.register(missing_target).unwrap();
708        planner
709            .register(action("low-utility", 80, 10, 10, &[]))
710            .unwrap();
711
712        assert!(matches!(
713            planner.authorize_scheduled_action(&snapshot, context(100), &BTreeSet::new(), "unmet",),
714            Err(ScheduledActionAuthorizationError::Excluded {
715                reason: ExclusionReason::RequirementsNotMet,
716                ..
717            })
718        ));
719        assert!(matches!(
720            planner.authorize_scheduled_action(
721                &snapshot,
722                context(100),
723                &BTreeSet::new(),
724                "missing-motivation",
725            ),
726            Err(ScheduledActionAuthorizationError::Excluded {
727                reason: ExclusionReason::NoEligibleHypothesis,
728                ..
729            })
730        ));
731        assert!(matches!(
732            planner.authorize_scheduled_action(
733                &snapshot,
734                context(100),
735                &BTreeSet::new(),
736                "missing-target",
737            ),
738            Err(ScheduledActionAuthorizationError::Excluded {
739                reason: ExclusionReason::NoEligibleVerificationTarget,
740                ..
741            })
742        ));
743        assert!(matches!(
744            planner.authorize_scheduled_action(
745                &snapshot,
746                context(100).with_minimum_utility(UtilityScore::from_units(u64::MAX)),
747                &BTreeSet::new(),
748                "low-utility",
749            ),
750            Err(ScheduledActionAuthorizationError::Excluded {
751                reason: ExclusionReason::BelowMinimumUtility { .. },
752                ..
753            })
754        ));
755    }
756
757    #[test]
758    fn replanning_excludes_policy_suppressed_actions() {
759        let knowledge = knowledge_with_hypothesis((80, 20));
760        let mut planner = AttackPlanner::new();
761        planner.register(action("zeta", 80, 10, 20, &[])).unwrap();
762        planner.register(action("alpha", 80, 10, 20, &[])).unwrap();
763
764        let plan = planner
765            .plan_with_suppressed(
766                &knowledge,
767                &subject(),
768                context(100),
769                &BTreeSet::from(["alpha".into()]),
770            )
771            .unwrap();
772
773        assert_eq!(plan.steps().len(), 1);
774        assert_eq!(plan.steps()[0].action_id(), "zeta");
775        assert_eq!(plan.excluded()[0].action_id(), "alpha");
776        assert_eq!(
777            plan.excluded()[0].reason(),
778            &ExclusionReason::PolicySuppressed
779        );
780    }
781
782    #[test]
783    fn planner_consumes_suppressions_derived_from_experience() {
784        let knowledge = knowledge_with_hypothesis((80, 20));
785        let evidence_id = knowledge.snapshot_for_subject(&subject()).evidence()[0]
786            .id()
787            .clone();
788        let mut experience = ExperienceStore::new();
789        for attempt in 0..10 {
790            experience
791                .observe(
792                    Outcome::verified(
793                        format!("case:alpha:{attempt}"),
794                        subject(),
795                        "alpha",
796                        "hypothesis:laravel",
797                        "verify.alpha",
798                        VerificationStage::Active,
799                        OutcomeStatus::ConfirmedNegative,
800                        Probability::from_percent(80).unwrap(),
801                        "active negative control rejected alpha",
802                        BTreeSet::from([evidence_id.clone()]),
803                    )
804                    .unwrap(),
805                )
806                .unwrap();
807        }
808        let suppressed = experience.suppressed_actions(&subject(), ExperiencePolicy::default());
809        let mut planner = AttackPlanner::new();
810        planner.register(action("alpha", 80, 10, 20, &[])).unwrap();
811        planner.register(action("zeta", 80, 10, 20, &[])).unwrap();
812
813        let plan = planner
814            .plan_with_suppressed(&knowledge, &subject(), context(100), &suppressed)
815            .unwrap();
816
817        assert_eq!(plan.steps().len(), 1);
818        assert_eq!(plan.steps()[0].action_id(), "zeta");
819        assert_eq!(plan.excluded()[0].action_id(), "alpha");
820        assert_eq!(
821            plan.excluded()[0].reason(),
822            &ExclusionReason::PolicySuppressed
823        );
824    }
825
826    #[test]
827    fn planner_places_prerequisites_before_high_utility_action() {
828        let knowledge = knowledge_with_hypothesis((90, 10));
829        let mut planner = AttackPlanner::new();
830        planner
831            .register(action("discovery", 10, 10, 40, &[]))
832            .unwrap();
833        planner
834            .register(action("active.verify", 95, 30, 10, &["discovery"]))
835            .unwrap();
836
837        let plan = planner.plan(&knowledge, &subject(), context(40)).unwrap();
838
839        assert_eq!(plan.steps()[0].action_id(), "discovery");
840        assert_eq!(plan.steps()[1].action_id(), "active.verify");
841        assert_eq!(plan.total_cost(), 40);
842    }
843
844    #[test]
845    fn budget_exclusion_includes_dependency_closure_cost() {
846        let knowledge = knowledge_with_hypothesis((90, 10));
847        let mut planner = AttackPlanner::new();
848        planner
849            .register(action("discovery", 10, 10, 40, &[]))
850            .unwrap();
851        planner
852            .register(action("active.verify", 95, 30, 10, &["discovery"]))
853            .unwrap();
854
855        let plan = planner.plan(&knowledge, &subject(), context(35)).unwrap();
856
857        assert_eq!(plan.steps().len(), 1);
858        assert_eq!(plan.steps()[0].action_id(), "discovery");
859        let active = plan
860            .excluded()
861            .iter()
862            .find(|excluded| excluded.action_id() == "active.verify")
863            .unwrap();
864        assert_eq!(
865            active.reason(),
866            &ExclusionReason::BudgetExceeded {
867                required: 40,
868                remaining: 35,
869            }
870        );
871    }
872
873    #[test]
874    fn risk_and_confidence_filters_are_explainable() {
875        let knowledge = knowledge_with_hypothesis((60, 40));
876        let mut planner = AttackPlanner::new();
877        planner.register(action("risky", 90, 10, 90, &[])).unwrap();
878        let strict_confidence = AttackAction::new(
879            "uncertain",
880            "plugin.uncertain",
881            Expression::equals(KnowledgeLayer::Hypothesis, stack_predicate(), stack_value()),
882            HypothesisSelector::new(
883                stack_predicate(),
884                stack_value(),
885                Probability::from_percent(90).unwrap(),
886                RequiredStrength::Strong,
887            ),
888            BenefitScore::from_percent(80).unwrap(),
889            ActionCost::new(10).unwrap(),
890            RiskScore::from_percent(10).unwrap(),
891            BTreeSet::new(),
892        )
893        .unwrap();
894        planner.register(strict_confidence).unwrap();
895        let unmet = AttackAction::new(
896            "unmet",
897            "plugin.unmet",
898            Expression::exists(
899                KnowledgeLayer::Evidence,
900                KnowledgePredicate::new("authentication", "mfa").unwrap(),
901            ),
902            HypothesisSelector::new(
903                stack_predicate(),
904                stack_value(),
905                Probability::from_percent(50).unwrap(),
906                RequiredStrength::Strong,
907            ),
908            BenefitScore::from_percent(80).unwrap(),
909            ActionCost::new(10).unwrap(),
910            RiskScore::from_percent(10).unwrap(),
911            BTreeSet::new(),
912        )
913        .unwrap();
914        planner.register(unmet).unwrap();
915
916        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
917
918        assert!(plan.steps().is_empty());
919        assert!(matches!(
920            plan.excluded()[0].reason(),
921            ExclusionReason::RiskLimitExceeded { .. } | ExclusionReason::NoEligibleHypothesis
922        ));
923        assert!(plan
924            .excluded()
925            .iter()
926            .any(|excluded| matches!(excluded.reason(), ExclusionReason::NoEligibleHypothesis)));
927        assert!(plan.excluded().iter().any(|excluded| matches!(
928            excluded.reason(),
929            ExclusionReason::RiskLimitExceeded { .. }
930        )));
931        assert!(plan
932            .excluded()
933            .iter()
934            .any(|excluded| matches!(excluded.reason(), ExclusionReason::RequirementsNotMet)));
935    }
936
937    #[test]
938    fn dependency_validation_rejects_unknown_and_cycles() {
939        let knowledge = knowledge_with_hypothesis((80, 20));
940        let mut unknown = AttackPlanner::new();
941        unknown
942            .register(action("dependent", 80, 10, 20, &["missing"]))
943            .unwrap();
944        assert!(matches!(
945            unknown.plan(&knowledge, &subject(), context(100)),
946            Err(PlannerError::UnknownPrerequisite { .. })
947        ));
948
949        let mut cyclic = AttackPlanner::new();
950        cyclic.register(action("a", 80, 10, 20, &["b"])).unwrap();
951        cyclic.register(action("b", 80, 10, 20, &["a"])).unwrap();
952        assert!(matches!(
953            cyclic.plan(&knowledge, &subject(), context(100)),
954            Err(PlannerError::DependencyCycle { .. })
955        ));
956    }
957
958    #[test]
959    fn ineligible_dependency_blocks_dependent_action() {
960        let knowledge = knowledge_with_hypothesis((80, 20));
961        let blocked = AttackAction::new(
962            "blocked",
963            "plugin.blocked",
964            Expression::exists(
965                KnowledgeLayer::Evidence,
966                KnowledgePredicate::new("authentication", "mfa").unwrap(),
967            ),
968            HypothesisSelector::new(
969                stack_predicate(),
970                stack_value(),
971                Probability::from_percent(50).unwrap(),
972                RequiredStrength::Strong,
973            ),
974            BenefitScore::from_percent(80).unwrap(),
975            ActionCost::new(10).unwrap(),
976            RiskScore::from_percent(10).unwrap(),
977            BTreeSet::new(),
978        )
979        .unwrap();
980        let mut planner = AttackPlanner::new();
981        planner.register(blocked).unwrap();
982        planner
983            .register(action("dependent", 90, 10, 10, &["blocked"]))
984            .unwrap();
985
986        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
987
988        assert!(plan.steps().is_empty());
989        let dependent = plan
990            .excluded()
991            .iter()
992            .find(|excluded| excluded.action_id() == "dependent")
993            .unwrap();
994        assert_eq!(
995            dependent.reason(),
996            &ExclusionReason::DependencyUnavailable {
997                prerequisite: "blocked".into(),
998            }
999        );
1000    }
1001
1002    #[test]
1003    fn action_registration_and_wire_invariants_are_enforced() {
1004        let action = action("sqli.verify", 80, 10, 20, &[]);
1005        let encoded = serde_json::to_value(&action).unwrap();
1006        assert!(encoded.get("verification_target").is_none());
1007        assert!(encoded.get("payload_claim_policy_guard").is_none());
1008        assert_eq!(
1009            serde_json::from_value::<AttackAction>(encoded).unwrap(),
1010            action
1011        );
1012        assert!(ActionCost::new(0).is_err());
1013        assert!(RiskScore::from_basis_points(0).is_err());
1014        assert!(BenefitScore::from_basis_points(10_001).is_err());
1015
1016        let mut planner = AttackPlanner::new();
1017        assert_eq!(
1018            planner.register(action.clone()).unwrap(),
1019            PlannerWrite::Inserted
1020        );
1021        assert_eq!(
1022            planner.register(action.clone()).unwrap(),
1023            PlannerWrite::Unchanged
1024        );
1025        assert!(matches!(
1026            planner.register(
1027                action
1028                    .clone()
1029                    .with_verification_target(VerificationTarget::KnowledgeOnly)
1030            ),
1031            Err(PlannerError::ActionIdentityConflict { .. })
1032        ));
1033        let conflicting = AttackAction::new(
1034            action.id(),
1035            "plugin.other",
1036            action.requirements.clone(),
1037            action.confidence_source.clone(),
1038            action.gain,
1039            action.cost,
1040            action.risk,
1041            BTreeSet::new(),
1042        )
1043        .unwrap();
1044        assert!(matches!(
1045            planner.register(conflicting),
1046            Err(PlannerError::ActionIdentityConflict { .. })
1047        ));
1048    }
1049
1050    #[test]
1051    fn verification_targets_round_trip_and_reserved_typos_fail_closed() {
1052        let knowledge_only = action("form.discover", 80, 10, 20, &[])
1053            .with_verification_target(VerificationTarget::KnowledgeOnly);
1054        let encoded = serde_json::to_value(&knowledge_only).unwrap();
1055        assert_eq!(encoded["verification_target"], "knowledge_only");
1056        assert_eq!(encoded["payload_claim_policy_guard"], true);
1057        let mut unguarded = encoded.clone();
1058        unguarded
1059            .as_object_mut()
1060            .unwrap()
1061            .remove("payload_claim_policy_guard");
1062        assert!(serde_json::from_value::<AttackAction>(unguarded).is_err());
1063        assert_eq!(
1064            serde_json::from_value::<AttackAction>(encoded).unwrap(),
1065            knowledge_only
1066        );
1067
1068        let distinct_selector = HypothesisSelector::new(
1069            KnowledgePredicate::new("auth", "mechanism").unwrap(),
1070            EvidenceValue::Text("http-basic".to_owned()),
1071            Probability::from_percent(60).unwrap(),
1072            RequiredStrength::Any,
1073        );
1074        let distinct = action("auth.verify", 80, 10, 20, &[])
1075            .with_verification_target(VerificationTarget::Distinct(distinct_selector));
1076        assert_eq!(
1077            serde_json::from_value::<AttackAction>(serde_json::to_value(&distinct).unwrap())
1078                .unwrap(),
1079            distinct
1080        );
1081
1082        let mut misspelled = serde_json::to_value(action("typo", 80, 10, 20, &[])).unwrap();
1083        misspelled["verification_targte"] = serde_json::json!("knowledge_only");
1084        assert!(serde_json::from_value::<AttackAction>(misspelled).is_err());
1085    }
1086
1087    #[test]
1088    fn planner_separates_confidence_from_resolved_verification_target() {
1089        let knowledge = knowledge_with_hypothesis((80, 20));
1090        let target_predicate = KnowledgePredicate::new("auth", "mechanism").unwrap();
1091        let target_value = EvidenceValue::Text("http-basic".to_owned());
1092        let mut target = Hypothesis::with_id(
1093            "hypothesis:http-basic",
1094            subject(),
1095            target_predicate.clone(),
1096            target_value.clone(),
1097            Probability::from_percent(80).unwrap(),
1098        )
1099        .unwrap();
1100        target.set_strength(HypothesisStrength::Strong);
1101        target.set_state(HypothesisState::Supported);
1102        knowledge.upsert_hypothesis(target).unwrap();
1103
1104        let mut planner = AttackPlanner::new();
1105        planner
1106            .register(action("motivation", 80, 10, 20, &[]))
1107            .unwrap();
1108        planner
1109            .register(
1110                action("distinct", 80, 10, 20, &[]).with_verification_target(
1111                    VerificationTarget::Distinct(HypothesisSelector::new(
1112                        target_predicate,
1113                        target_value,
1114                        Probability::from_percent(60).unwrap(),
1115                        RequiredStrength::Any,
1116                    )),
1117                ),
1118            )
1119            .unwrap();
1120        planner
1121            .register(
1122                action("knowledge-only", 80, 10, 20, &[])
1123                    .with_verification_target(VerificationTarget::KnowledgeOnly),
1124            )
1125            .unwrap();
1126
1127        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
1128        let step = |action_id| {
1129            plan.steps()
1130                .iter()
1131                .find(|step| step.action_id() == action_id)
1132                .unwrap()
1133        };
1134
1135        for action_id in ["motivation", "distinct", "knowledge-only"] {
1136            assert_eq!(
1137                step(action_id).confidence_hypothesis_id(),
1138                "hypothesis:laravel"
1139            );
1140        }
1141        assert_eq!(
1142            step("motivation").verification_target().hypothesis_id(),
1143            Some("hypothesis:laravel")
1144        );
1145        assert_eq!(
1146            step("distinct").verification_target().hypothesis_id(),
1147            Some("hypothesis:http-basic")
1148        );
1149        assert_eq!(
1150            step("knowledge-only").verification_target(),
1151            &ResolvedVerificationTarget::KnowledgeOnly
1152        );
1153        assert!(!step("knowledge-only")
1154            .verification_target()
1155            .applies_hypothesis_transition());
1156    }
1157
1158    #[test]
1159    fn missing_distinct_verification_target_is_excluded_fail_closed() {
1160        let knowledge = knowledge_with_hypothesis((80, 20));
1161        let distinct_action = action("distinct", 80, 10, 20, &[]).with_verification_target(
1162            VerificationTarget::Distinct(HypothesisSelector::new(
1163                KnowledgePredicate::new("auth", "mechanism").unwrap(),
1164                EvidenceValue::Text("http-basic".to_owned()),
1165                Probability::from_percent(60).unwrap(),
1166                RequiredStrength::Any,
1167            )),
1168        );
1169        let mut planner = AttackPlanner::new();
1170        planner.register(distinct_action).unwrap();
1171
1172        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
1173
1174        assert!(plan.steps().is_empty());
1175        assert_eq!(
1176            plan.excluded()[0].reason(),
1177            &ExclusionReason::NoEligibleVerificationTarget
1178        );
1179
1180        let same_as_motivation = action("same-target", 80, 10, 20, &[]).with_verification_target(
1181            VerificationTarget::Distinct(HypothesisSelector::new(
1182                stack_predicate(),
1183                stack_value(),
1184                Probability::from_percent(50).unwrap(),
1185                RequiredStrength::Strong,
1186            )),
1187        );
1188        let mut planner = AttackPlanner::new();
1189        planner.register(same_as_motivation).unwrap();
1190        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
1191        assert!(plan.steps().is_empty());
1192        assert_eq!(
1193            plan.excluded()[0].reason(),
1194            &ExclusionReason::NoEligibleVerificationTarget
1195        );
1196    }
1197
1198    #[test]
1199    fn planner_carries_exact_strategy_revision_without_exposing_payloads() {
1200        let knowledge = knowledge_with_hypothesis((80, 20));
1201        let strategy = PayloadStrategyRef::new("visibility.control-pair", 2).unwrap();
1202        let selected =
1203            action("visibility.compare", 80, 10, 20, &[]).with_payload_strategy(strategy.clone());
1204        let legacy = action("legacy.observe", 70, 10, 20, &[]);
1205
1206        let legacy_wire = serde_json::to_value(&legacy).unwrap();
1207        assert!(legacy_wire.get("payload_strategy").is_none());
1208        assert!(serde_json::from_value::<AttackAction>(legacy_wire)
1209            .unwrap()
1210            .payload_strategy()
1211            .is_none());
1212        let mut misspelled = serde_json::to_value(&legacy).unwrap();
1213        misspelled["payload_stratgey"] = serde_json::json!({
1214            "id": "visibility.control-pair",
1215            "revision": 1
1216        });
1217        assert!(serde_json::from_value::<AttackAction>(misspelled).is_err());
1218        let mut extended = serde_json::to_value(&legacy).unwrap();
1219        extended["future_extension"] = serde_json::json!({"accepted": true});
1220        assert!(serde_json::from_value::<AttackAction>(extended).is_ok());
1221
1222        let selected_wire = serde_json::to_value(&selected).unwrap();
1223        assert_eq!(selected_wire["payload_strategy"]["revision"], 2);
1224        assert_eq!(
1225            serde_json::from_value::<AttackAction>(selected_wire).unwrap(),
1226            selected
1227        );
1228
1229        let mut planner = AttackPlanner::new();
1230        planner.register(selected.clone()).unwrap();
1231        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
1232        assert_eq!(plan.steps()[0].payload_strategy(), Some(&strategy));
1233        assert_eq!(
1234            planner
1235                .action("visibility.compare")
1236                .and_then(AttackAction::payload_strategy),
1237            Some(&strategy)
1238        );
1239
1240        let conflicting = action("visibility.compare", 80, 10, 20, &[])
1241            .with_payload_strategy(PayloadStrategyRef::new("visibility.control-pair", 3).unwrap());
1242        assert!(matches!(
1243            planner.register(conflicting),
1244            Err(PlannerError::ActionIdentityConflict { .. })
1245        ));
1246    }
1247
1248    #[test]
1249    fn planner_accepts_hypotheses_materialized_by_rule_contracts() {
1250        let knowledge = KnowledgeBase::new();
1251        let evidence_predicate = KnowledgePredicate::new("technology", "framework").unwrap();
1252        knowledge
1253            .insert_evidence(Evidence::new(
1254                subject(),
1255                EvidenceKind::Technology,
1256                evidence_predicate.clone(),
1257                stack_value(),
1258                EvidenceSource::new("discovery", "framework-header").unwrap(),
1259                ConfidenceScore::from_percent(90).unwrap(),
1260            ))
1261            .unwrap();
1262        let calibration = EvidenceCalibration::new(
1263            EvidenceSelector::equals(evidence_predicate.clone(), stack_value()),
1264            Probability::from_percent(80).unwrap(),
1265            Probability::from_percent(20).unwrap(),
1266            "framework fingerprint",
1267        )
1268        .unwrap();
1269        let conclusion = HypothesisConclusion::new(
1270            stack_predicate(),
1271            stack_value(),
1272            Probability::from_percent(50).unwrap(),
1273            HypothesisStrength::Strong,
1274            HypothesisState::Supported,
1275            vec![calibration],
1276        )
1277        .unwrap();
1278        let rule = ReasoningRule::new(
1279            "detect.laravel",
1280            Expression::equals(KnowledgeLayer::Evidence, evidence_predicate, stack_value()),
1281            conclusion,
1282        )
1283        .unwrap();
1284        let mut rules = RuleEngine::new();
1285        rules.register(rule).unwrap();
1286        rules.apply(&knowledge, &subject()).unwrap();
1287
1288        let mut planner = AttackPlanner::new();
1289        planner
1290            .register(action("laravel.verify", 80, 10, 20, &[]))
1291            .unwrap();
1292        let plan = planner.plan(&knowledge, &subject(), context(100)).unwrap();
1293
1294        assert_eq!(plan.steps().len(), 1);
1295        assert_eq!(plan.steps()[0].action_id(), "laravel.verify");
1296        assert_eq!(
1297            plan.steps()[0].confidence_hypothesis_id(),
1298            "rule:14:detect.laravel:endpoint:https://example.test"
1299        );
1300    }
1301}