1use 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#[derive(Debug, Error)]
44#[non_exhaustive]
45pub enum PlannerError {
46 #[error("{field} must not be empty")]
48 EmptyValue { field: &'static str },
49
50 #[error("benefit score {0} exceeds 10,000 basis points")]
52 BenefitOutOfRange(u16),
53
54 #[error("risk score {0} must be between 1 and 10,000 basis points")]
56 RiskOutOfRange(u16),
57
58 #[error("action cost must be greater than zero")]
60 ZeroCost,
61
62 #[error("action {action_id} cannot depend on itself")]
64 SelfDependency { action_id: String },
65
66 #[error("action {action_id} references unknown prerequisite {prerequisite}")]
68 UnknownPrerequisite {
69 action_id: String,
71 prerequisite: String,
73 },
74
75 #[error("action dependency cycle includes {action_id}")]
77 DependencyCycle { action_id: String },
78
79 #[error("action identity {id} already has a different definition")]
81 ActionIdentityConflict { id: String },
82
83 #[error("planner produced no selection decision for action {action_id}")]
85 IncompleteDecision { action_id: String },
86
87 #[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}