1use termivar_core::EntityId;
15
16use crate::knowledge::KnowledgeSnapshot;
17use crate::planner::{AttackAction, AttackPlan, AttackPlanner, PlannerError, PlanningContext};
18
19use super::shadow_planning::{
20 defense_aware_shadow_plan, DefenseInteractionClass, ResourceDefenseSignal,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub struct DefensePlanningPolicy {
28 enabled: bool,
29}
30
31impl DefensePlanningPolicy {
32 pub const fn disabled() -> Self {
34 Self { enabled: false }
35 }
36
37 pub const fn enabled() -> Self {
39 Self { enabled: true }
40 }
41
42 pub const fn from_enabled(enabled: bool) -> Self {
44 Self { enabled }
45 }
46
47 pub const fn is_enabled(self) -> bool {
49 self.enabled
50 }
51}
52
53pub fn defense_aware_plan(
63 policy: DefensePlanningPolicy,
64 planner: &AttackPlanner,
65 snapshot: &KnowledgeSnapshot,
66 subject: &EntityId,
67 context: PlanningContext,
68 signal: &ResourceDefenseSignal,
69 classify: impl Fn(&AttackAction) -> DefenseInteractionClass,
70) -> Result<AttackPlan, PlannerError> {
71 if !policy.enabled {
72 return planner.plan_snapshot(snapshot, context);
73 }
74
75 let shadow = defense_aware_shadow_plan(planner, snapshot, subject, context, signal, classify)?;
79 Ok(shadow.shadow().clone())
80}
81
82#[cfg(test)]
83mod tests {
84 use std::collections::BTreeSet;
85
86 use termivar_core::{
87 BayesianEvidence, ConfidenceScore, Evidence, EvidenceKind, EvidenceSource, EvidenceValue,
88 Hypothesis, HypothesisState, HypothesisStrength, KnowledgePredicate, Probability,
89 };
90
91 use super::*;
92 use crate::defense::shadow_planning::ResourceDefenseObservation;
93 use crate::defense::state::DefenseState;
94 use crate::knowledge::KnowledgeBase;
95 use crate::planner::{
96 ActionCost, BenefitScore, ExclusionReason, HypothesisSelector, RequiredStrength, RiskScore,
97 };
98 use crate::{Expression, KnowledgeLayer};
99
100 fn subject() -> EntityId {
101 EntityId::new("endpoint:https://example.test/api/admin").unwrap()
102 }
103
104 fn other_subject() -> EntityId {
105 EntityId::new("endpoint:https://example.test/public").unwrap()
106 }
107
108 fn stack_predicate() -> KnowledgePredicate {
109 KnowledgePredicate::new("stack", "framework").unwrap()
110 }
111
112 fn stack_value() -> EvidenceValue {
113 EvidenceValue::Text("Laravel".into())
114 }
115
116 fn knowledge_with_hypothesis() -> KnowledgeBase {
117 let knowledge = KnowledgeBase::new();
118 let evidence = Evidence::new(
119 subject(),
120 EvidenceKind::Technology,
121 KnowledgePredicate::new("technology", "framework").unwrap(),
122 stack_value(),
123 EvidenceSource::new("discovery", "framework-header").unwrap(),
124 ConfidenceScore::from_percent(90).unwrap(),
125 );
126 knowledge.insert_evidence(evidence.clone()).unwrap();
127 let mut hypothesis = Hypothesis::with_id(
128 "hypothesis:laravel",
129 subject(),
130 stack_predicate(),
131 stack_value(),
132 Probability::from_percent(50).unwrap(),
133 )
134 .unwrap();
135 hypothesis
136 .observe(
137 BayesianEvidence::new(
138 evidence.id().clone(),
139 Probability::from_percent(80).unwrap(),
140 Probability::from_percent(20).unwrap(),
141 "framework fingerprint",
142 )
143 .unwrap(),
144 )
145 .unwrap();
146 hypothesis.set_strength(HypothesisStrength::Strong);
147 hypothesis.set_state(HypothesisState::Supported);
148 knowledge.upsert_hypothesis(hypothesis).unwrap();
149 knowledge
150 }
151
152 fn action(id: &str, gain: u8) -> AttackAction {
153 AttackAction::new(
154 id,
155 format!("plugin.{id}"),
156 Expression::equals(KnowledgeLayer::Hypothesis, stack_predicate(), stack_value()),
157 HypothesisSelector::new(
158 stack_predicate(),
159 stack_value(),
160 Probability::from_percent(50).unwrap(),
161 RequiredStrength::Strong,
162 ),
163 BenefitScore::from_percent(gain).unwrap(),
164 ActionCost::new(10).unwrap(),
165 RiskScore::from_percent(20).unwrap(),
166 BTreeSet::new(),
167 )
168 .unwrap()
169 }
170
171 fn classify(action: &AttackAction) -> DefenseInteractionClass {
172 match action.id() {
173 "local.report" => DefenseInteractionClass::LocalOnly,
174 "passive.discovery" => DefenseInteractionClass::Passive,
175 "active.verify" => DefenseInteractionClass::ActiveVerification,
176 "mutating.fuzz" => DefenseInteractionClass::Mutating,
177 other => panic!("unclassified test action {other}"),
178 }
179 }
180
181 fn planner() -> AttackPlanner {
182 let mut planner = AttackPlanner::new();
183 planner.register(action("local.report", 60)).unwrap();
184 planner.register(action("passive.discovery", 58)).unwrap();
185 planner.register(action("active.verify", 54)).unwrap();
186 planner.register(action("mutating.fuzz", 52)).unwrap();
187 planner
188 }
189
190 fn context() -> PlanningContext {
191 PlanningContext::new(
192 BenefitScore::from_percent(90).unwrap(),
193 1_000,
194 RiskScore::from_percent(80).unwrap(),
195 )
196 }
197
198 fn backoff_signal(resource: EntityId) -> ResourceDefenseSignal {
199 let state = DefenseState::observe(429, &[], "slow down");
200 let evidence = vec![termivar_core::EvidenceId::parse("defense/e1").unwrap()];
201 ResourceDefenseSignal::aggregate(
202 resource,
203 &[ResourceDefenseObservation::new(&state, None, evidence)],
204 )
205 }
206
207 fn snapshot() -> KnowledgeSnapshot {
208 knowledge_with_hypothesis().snapshot_for_subject(&subject())
210 }
211
212 fn plan_with(
213 snapshot: &KnowledgeSnapshot,
214 policy: DefensePlanningPolicy,
215 signal: &ResourceDefenseSignal,
216 ) -> AttackPlan {
217 defense_aware_plan(
218 policy,
219 &planner(),
220 snapshot,
221 &subject(),
222 context(),
223 signal,
224 classify,
225 )
226 .unwrap()
227 }
228
229 fn baseline_plan(snapshot: &KnowledgeSnapshot) -> AttackPlan {
230 planner().plan_snapshot(snapshot, context()).unwrap()
231 }
232
233 fn step_ids(plan: &AttackPlan) -> BTreeSet<&str> {
234 plan.steps().iter().map(|step| step.action_id()).collect()
235 }
236
237 #[test]
238 fn disabled_flag_preserves_existing_plan_byte_for_byte() {
239 let strong = {
241 let first = DefenseState::observe(403, &[], "forbidden");
242 let second = DefenseState::observe(406, &[], "not acceptable");
243 ResourceDefenseSignal::aggregate(
244 subject(),
245 &[
246 ResourceDefenseObservation::new(&first, None, Vec::new()),
247 ResourceDefenseObservation::new(&second, None, Vec::new()),
248 ],
249 )
250 };
251 let snapshot = snapshot();
252 assert_eq!(
253 plan_with(&snapshot, DefensePlanningPolicy::disabled(), &strong),
254 baseline_plan(&snapshot)
255 );
256 assert_eq!(
257 plan_with(&snapshot, DefensePlanningPolicy::default(), &strong),
258 baseline_plan(&snapshot)
259 );
260 assert_eq!(
261 serde_json::to_vec(&plan_with(
262 &snapshot,
263 DefensePlanningPolicy::default(),
264 &strong,
265 ))
266 .unwrap(),
267 serde_json::to_vec(&baseline_plan(&snapshot)).unwrap()
268 );
269 }
270
271 #[test]
272 fn enabled_flag_suppresses_defense_actions_with_a_distinct_reason() {
273 let plan = plan_with(
274 &snapshot(),
275 DefensePlanningPolicy::enabled(),
276 &backoff_signal(subject()),
277 );
278 assert!(!step_ids(&plan).contains("active.verify"));
280 assert!(!step_ids(&plan).contains("mutating.fuzz"));
281 assert!(step_ids(&plan).contains("passive.discovery"));
282 assert!(step_ids(&plan).contains("local.report"));
283
284 let reasons: Vec<_> = plan
286 .excluded()
287 .iter()
288 .filter(|excluded| {
289 excluded.action_id() == "active.verify" || excluded.action_id() == "mutating.fuzz"
290 })
291 .map(|excluded| excluded.reason().clone())
292 .collect();
293 assert_eq!(reasons.len(), 2);
294 assert!(reasons
295 .iter()
296 .all(|reason| *reason == ExclusionReason::DefenseSuppressed));
297 }
298
299 #[test]
300 fn defense_suppressed_strategy_never_reaches_a_plan_step() {
301 let plan = plan_with(
304 &snapshot(),
305 DefensePlanningPolicy::enabled(),
306 &backoff_signal(subject()),
307 );
308 for step in plan.steps() {
309 assert_ne!(step.action_id(), "active.verify");
310 assert_ne!(step.action_id(), "mutating.fuzz");
311 }
312 }
313
314 #[test]
315 fn enabled_but_unrelated_resource_preserves_the_plan() {
316 let snapshot = snapshot();
317 let plan = plan_with(
318 &snapshot,
319 DefensePlanningPolicy::enabled(),
320 &backoff_signal(other_subject()),
321 );
322 assert_eq!(plan, baseline_plan(&snapshot));
323 }
324
325 #[test]
326 fn enabled_but_proceed_preserves_the_plan() {
327 let snapshot = snapshot();
328 let proceed = ResourceDefenseSignal::proceed(subject());
329 assert_eq!(
330 plan_with(&snapshot, DefensePlanningPolicy::enabled(), &proceed),
331 baseline_plan(&snapshot)
332 );
333 }
334
335 #[test]
336 fn enforcement_is_deterministic() {
337 let snapshot = snapshot();
338 let signal = backoff_signal(subject());
339 assert_eq!(
340 plan_with(&snapshot, DefensePlanningPolicy::enabled(), &signal),
341 plan_with(&snapshot, DefensePlanningPolicy::enabled(), &signal)
342 );
343 }
344
345 #[test]
346 fn policy_is_disabled_by_default() {
347 assert!(!DefensePlanningPolicy::default().is_enabled());
348 }
349
350 #[test]
351 fn disabled_policy_does_not_evaluate_or_require_defense_metadata() {
352 let snapshot = snapshot();
356 let panicking_classifier = |_: &AttackAction| -> DefenseInteractionClass {
357 panic!("disabled policy classified an action")
358 };
359
360 let halt = {
361 let first = DefenseState::observe(403, &[], "forbidden");
362 let second = DefenseState::observe(406, &[], "not acceptable");
363 ResourceDefenseSignal::aggregate(
364 subject(),
365 &[
366 ResourceDefenseObservation::new(&first, None, Vec::new()),
367 ResourceDefenseObservation::new(&second, None, Vec::new()),
368 ],
369 )
370 };
371
372 let plan = defense_aware_plan(
373 DefensePlanningPolicy::disabled(),
374 &planner(),
375 &snapshot,
376 &subject(),
377 context(),
378 &halt,
379 panicking_classifier,
380 )
381 .unwrap();
382 assert_eq!(plan, baseline_plan(&snapshot));
383 }
384
385 #[test]
386 fn defense_suppression_is_not_recorded_as_execution_failure() {
387 let snapshot = snapshot();
394 let plan = plan_with(
395 &snapshot,
396 DefensePlanningPolicy::enabled(),
397 &backoff_signal(subject()),
398 );
399
400 let suppressed: Vec<String> = plan
401 .excluded()
402 .iter()
403 .filter(|excluded| excluded.reason() == &ExclusionReason::DefenseSuppressed)
404 .map(|excluded| excluded.action_id().to_owned())
405 .collect();
406 assert!(suppressed.contains(&"active.verify".to_owned()));
407
408 let steps = step_ids(&plan);
409 for action_id in &suppressed {
410 assert!(
411 !steps.contains(action_id.as_str()),
412 "{action_id} was suppressed yet reached a plan step"
413 );
414 }
415 assert!(plan
416 .excluded()
417 .iter()
418 .all(|excluded| excluded.reason() != &ExclusionReason::PolicySuppressed));
419 }
420}