1use serde::Serialize;
17use thiserror::Error;
18
19use crate::{
20 http_evidence::HttpRequestBroker, DecisionExecutorRegistry, DecisionLoop, HttpEvidencePolicy,
21 KnowledgeBase, StandardWebAttackInstallReport, StandardWebAttackProfile,
22 StandardWebDiscoveryExecutorProfile, StandardWebDiscoveryInstallReport,
23 StandardWebExecutionError, StandardWebInstallReport, StandardWebPlanningError,
24 StandardWebReasoning, StandardWebReasoningError, StandardWebVerificationError,
25 StandardWebVerificationInstallReport, StandardWebVerificationProfile,
26};
27
28#[derive(Debug, Error)]
30#[non_exhaustive]
31pub enum StandardWebDecisionError {
32 #[error(transparent)]
34 Reasoning(#[from] StandardWebReasoningError),
35
36 #[error(transparent)]
38 Planning(#[from] StandardWebPlanningError),
39
40 #[error(transparent)]
42 Execution(#[from] StandardWebExecutionError),
43
44 #[error(transparent)]
46 Verification(#[from] StandardWebVerificationError),
47}
48
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
51pub struct StandardWebDecisionInstallReport {
52 reasoning: StandardWebInstallReport,
53 planning: StandardWebAttackInstallReport,
54 execution: StandardWebDiscoveryInstallReport,
55 verification: StandardWebVerificationInstallReport,
56}
57
58impl StandardWebDecisionInstallReport {
59 pub fn reasoning(self) -> StandardWebInstallReport {
61 self.reasoning
62 }
63
64 pub fn planning(self) -> StandardWebAttackInstallReport {
66 self.planning
67 }
68
69 pub fn execution(self) -> StandardWebDiscoveryInstallReport {
71 self.execution
72 }
73
74 pub fn verification(self) -> StandardWebVerificationInstallReport {
76 self.verification
77 }
78}
79
80pub struct StandardWebDecisionProfile {
117 reasoning: StandardWebReasoning,
118 planning: StandardWebAttackProfile,
119 execution: StandardWebDiscoveryExecutorProfile,
120 verification: StandardWebVerificationProfile,
121}
122
123impl StandardWebDecisionProfile {
124 pub fn new(policy: HttpEvidencePolicy) -> Result<Self, StandardWebDecisionError> {
126 Self::build(StandardWebDiscoveryExecutorProfile::new(policy)?)
127 }
128
129 pub(crate) fn new_with_request_broker(
130 requests: HttpRequestBroker,
131 ) -> Result<Self, StandardWebDecisionError> {
132 Self::build(StandardWebDiscoveryExecutorProfile::new_with_request_broker(requests)?)
133 }
134
135 pub(crate) fn new_with_request_broker_and_assessment_projection(
136 requests: HttpRequestBroker,
137 ) -> Result<Self, StandardWebDecisionError> {
138 Self::build(
139 StandardWebDiscoveryExecutorProfile::new_with_request_broker_and_assessment_projection(
140 requests, true,
141 )?,
142 )
143 }
144
145 fn build(
146 execution: StandardWebDiscoveryExecutorProfile,
147 ) -> Result<Self, StandardWebDecisionError> {
148 Ok(Self {
149 reasoning: StandardWebReasoning::new()?,
150 planning: StandardWebAttackProfile::new()?,
151 execution,
152 verification: StandardWebVerificationProfile::new()?,
153 })
154 }
155
156 pub fn install(
158 &self,
159 knowledge: &KnowledgeBase,
160 decision_loop: &mut DecisionLoop,
161 executors: &mut DecisionExecutorRegistry,
162 ) -> Result<StandardWebDecisionInstallReport, StandardWebDecisionError> {
163 let mut prospective_loop = decision_loop.clone();
164 let mut prospective_executors = executors.clone();
165
166 let planning = self.planning.install(prospective_loop.planner_mut())?;
167 let verification = self
168 .verification
169 .install(prospective_loop.verification_mut())?;
170 let execution = self.execution.install(&mut prospective_executors)?;
171 let reasoning = self
172 .reasoning
173 .install(knowledge, prospective_loop.rules_mut())?;
174
175 *decision_loop = prospective_loop;
176 *executors = prospective_executors;
177 Ok(StandardWebDecisionInstallReport {
178 reasoning,
179 planning,
180 execution,
181 verification,
182 })
183 }
184
185 pub fn reasoning(&self) -> &StandardWebReasoning {
187 &self.reasoning
188 }
189
190 pub fn planning(&self) -> &StandardWebAttackProfile {
192 &self.planning
193 }
194
195 pub fn execution(&self) -> &StandardWebDiscoveryExecutorProfile {
197 &self.execution
198 }
199
200 pub fn verification(&self) -> &StandardWebVerificationProfile {
202 &self.verification
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use std::sync::Arc;
209
210 use termivar_core::{
211 ConceptId, ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource,
212 EvidenceValue, HypothesisState, KnowledgePredicate, OntologyConcept, OutcomeStatus,
213 };
214 use tokio::{
215 io::{AsyncReadExt, AsyncWriteExt},
216 net::TcpListener,
217 };
218 use url::Url;
219
220 use super::*;
221 use crate::{
222 AdaptationLimits, BenefitScore, DecisionExecutionStage, DecisionLoopConfig,
223 DecisionRunnerAdapter, DecisionRunnerTurn, DecisionSession, ExperiencePolicy,
224 ExperienceStore, HttpEvidenceExecutor, HttpProbeMethod, PlanningContext, RiskScore,
225 StandardWebActionKind, SubjectHttpProbeProvider, STANDARD_WEB_ACTION_COUNT,
226 STANDARD_WEB_AXIOM_COUNT, STANDARD_WEB_CONCEPT_COUNT,
227 STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT, STANDARD_WEB_RULE_COUNT,
228 STANDARD_WEB_VERIFICATION_RULE_COUNT,
229 };
230
231 fn decision_loop() -> DecisionLoop {
232 DecisionLoop::new(
233 DecisionLoopConfig::new(
234 PlanningContext::new(
235 BenefitScore::from_percent(90).unwrap(),
236 200,
237 RiskScore::from_percent(100).unwrap(),
238 ),
239 AdaptationLimits::default(),
240 ExperiencePolicy::new(3).unwrap(),
241 8,
242 )
243 .unwrap(),
244 )
245 }
246
247 fn profile(target: Url) -> StandardWebDecisionProfile {
248 StandardWebDecisionProfile::new(HttpEvidencePolicy::for_origin(target).unwrap()).unwrap()
249 }
250
251 #[test]
252 fn composite_install_is_complete_and_idempotent() {
253 let target = Url::parse("https://example.test/app").unwrap();
254 let profile = profile(target);
255 let knowledge = KnowledgeBase::new();
256 let mut decision_loop = decision_loop();
257 let mut executors = DecisionExecutorRegistry::new();
258
259 let first = profile
260 .install(&knowledge, &mut decision_loop, &mut executors)
261 .unwrap();
262 let second = profile
263 .install(&knowledge, &mut decision_loop, &mut executors)
264 .unwrap();
265
266 assert_eq!(
267 first.reasoning().concepts_inserted(),
268 STANDARD_WEB_CONCEPT_COUNT
269 );
270 assert_eq!(
271 first.reasoning().axioms_inserted(),
272 STANDARD_WEB_AXIOM_COUNT
273 );
274 assert_eq!(first.reasoning().rules_inserted(), STANDARD_WEB_RULE_COUNT);
275 assert_eq!(
276 first.planning().actions_inserted(),
277 STANDARD_WEB_ACTION_COUNT
278 );
279 assert_eq!(
280 first.execution().executors_inserted(),
281 STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT
282 );
283 assert_eq!(
284 first.verification().total_rules_inserted(),
285 STANDARD_WEB_VERIFICATION_RULE_COUNT
286 );
287 assert_eq!(second, StandardWebDecisionInstallReport::default());
288 assert_eq!(decision_loop.rules().len(), STANDARD_WEB_RULE_COUNT);
289 assert_eq!(decision_loop.planner().len(), STANDARD_WEB_ACTION_COUNT);
290 assert_eq!(decision_loop.verification().passive().len(), 16);
291 assert_eq!(decision_loop.verification().active().len(), 16);
292 assert_eq!(executors.len(), STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT);
293 }
294
295 #[test]
296 fn executor_route_conflict_rolls_back_every_layer() {
297 let target = Url::parse("https://example.test/app").unwrap();
298 let policy = HttpEvidencePolicy::for_origin(target.clone()).unwrap();
299 let profile = StandardWebDecisionProfile::new(policy.clone()).unwrap();
300 let knowledge = KnowledgeBase::new();
301 let ontology_before = knowledge.stats().ontology;
302 let mut decision_loop = decision_loop();
303 let mut executors = DecisionExecutorRegistry::new();
304 let custom = HttpEvidenceExecutor::with_id(
305 "host.custom",
306 policy,
307 Arc::new(SubjectHttpProbeProvider::new(HttpProbeMethod::Head)),
308 )
309 .unwrap();
310 executors.register(Arc::new(custom)).unwrap();
311 executors
312 .route_action(
313 DecisionExecutionStage::Passive,
314 StandardWebActionKind::LaravelRouteDiscovery.action_id(),
315 "host.custom",
316 )
317 .unwrap();
318
319 assert!(matches!(
320 profile.install(&knowledge, &mut decision_loop, &mut executors),
321 Err(StandardWebDecisionError::Execution(_))
322 ));
323 assert_eq!(knowledge.stats().ontology, ontology_before);
324 assert!(decision_loop.rules().is_empty());
325 assert!(decision_loop.planner().is_empty());
326 assert!(decision_loop.verification().passive().is_empty());
327 assert!(decision_loop.verification().active().is_empty());
328 assert_eq!(executors.len(), 1);
329 assert!(executors.contains("host.custom"));
330 }
331
332 #[test]
333 fn ontology_conflict_does_not_commit_preflighted_runtime_layers() {
334 let target = Url::parse("https://example.test/app").unwrap();
335 let profile = profile(target);
336 let knowledge = KnowledgeBase::new();
337 knowledge
338 .register_concept(
339 OntologyConcept::new(ConceptId::new("technology").unwrap(), "Conflicting label")
340 .unwrap(),
341 )
342 .unwrap();
343 let ontology_before = knowledge.stats().ontology;
344 let mut decision_loop = decision_loop();
345 let mut executors = DecisionExecutorRegistry::new();
346
347 assert!(matches!(
348 profile.install(&knowledge, &mut decision_loop, &mut executors),
349 Err(StandardWebDecisionError::Reasoning(_))
350 ));
351 assert_eq!(knowledge.stats().ontology, ontology_before);
352 assert!(decision_loop.rules().is_empty());
353 assert!(decision_loop.planner().is_empty());
354 assert!(decision_loop.verification().passive().is_empty());
355 assert!(executors.is_empty());
356 }
357
358 async fn serve_basic_challenge() -> Url {
359 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
360 let address = listener.local_addr().unwrap();
361 tokio::spawn(async move {
362 let (mut stream, _) = listener.accept().await.unwrap();
363 let mut request = [0_u8; 2048];
364 let bytes = stream.read(&mut request).await.unwrap();
365 assert!(String::from_utf8_lossy(&request[..bytes]).starts_with("HEAD "));
366 stream
367 .write_all(
368 b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"admin\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
369 )
370 .await
371 .unwrap();
372 stream.shutdown().await.unwrap();
373 });
374 Url::parse(&format!("http://{address}/admin")).unwrap()
375 }
376
377 #[tokio::test]
378 async fn composed_stack_runs_evidence_to_confirmed_outcome() {
379 let target = serve_basic_challenge().await;
380 let subject = EntityId::new(format!("endpoint:{target}")).unwrap();
381 let knowledge = KnowledgeBase::new();
382 knowledge
383 .insert_evidence(Evidence::new(
384 subject.clone(),
385 EvidenceKind::Authentication,
386 KnowledgePredicate::new("http.header", "www-authenticate").unwrap(),
387 EvidenceValue::Text("Basic realm=\"initial\"".to_owned()),
388 EvidenceSource::new("http.evidence", "initial-challenge").unwrap(),
389 ConfidenceScore::MAX,
390 ))
391 .unwrap();
392 let profile = profile(target);
393 let mut decision_loop = decision_loop();
394 let mut executors = DecisionExecutorRegistry::new();
395 profile
396 .install(&knowledge, &mut decision_loop, &mut executors)
397 .unwrap();
398 let mut experience = ExperienceStore::new();
399 let mut session = DecisionSession::new(subject);
400 let planned = decision_loop
401 .plan_next(&knowledge, &experience, &mut session)
402 .unwrap();
403 assert_eq!(
404 planned.plan().steps()[0].action_id(),
405 StandardWebActionKind::HttpBasicAuthBoundary.action_id()
406 );
407
408 let turn = DecisionRunnerAdapter::new(executors)
409 .drive_command(
410 &decision_loop,
411 planned.command(),
412 &knowledge,
413 &mut experience,
414 &mut session,
415 )
416 .await
417 .unwrap();
418 let DecisionRunnerTurn::Outcome { decision, .. } = turn else {
419 panic!("planned execution must produce an outcome turn");
420 };
421
422 assert_eq!(
423 decision.verification().outcome().status(),
424 OutcomeStatus::Success
425 );
426 let hypothesis_id = decision.verification().case().hypothesis_id();
427 assert_eq!(
428 knowledge.hypothesis(hypothesis_id).unwrap().state(),
429 HypothesisState::Confirmed
430 );
431 }
432}