Skip to main content

stygian_charon/
policy.rs

1use std::collections::BTreeMap;
2
3use crate::har;
4use crate::investigation::{infer_requirements, investigate_har};
5use crate::types::{
6    AdapterStrategy, AntiBotProvider, ExecutionMode, IntegrationRecommendation,
7    InvestigationBundle, InvestigationReport, RequirementLevel, RequirementsProfile, RuntimePolicy,
8    SessionMode, TelemetryLevel,
9};
10
11/// Build a concrete runtime policy from an investigation report and inferred requirements.
12#[must_use]
13pub fn build_runtime_policy(
14    report: &InvestigationReport,
15    requirements: &RequirementsProfile,
16) -> RuntimePolicy {
17    let blocked_ratio = if report.total_requests == 0 {
18        0.0
19    } else {
20        to_f64(report.blocked_requests) / to_f64(report.total_requests)
21    };
22
23    let mut policy = RuntimePolicy {
24        execution_mode: ExecutionMode::Http,
25        session_mode: SessionMode::Stateless,
26        telemetry_level: TelemetryLevel::Standard,
27        rate_limit_rps: 3.0,
28        max_retries: 2,
29        backoff_base_ms: 250,
30        enable_warmup: false,
31        enforce_webrtc_proxy_only: false,
32        sticky_session_ttl_secs: None,
33        required_stygian_features: requirements
34            .recommendation
35            .required_stygian_features
36            .clone(),
37        config_hints: requirements.recommendation.config_hints.clone(),
38        risk_score: blocked_ratio,
39    };
40
41    apply_strategy_defaults(&mut policy, &requirements.recommendation);
42
43    // Apply SLO-based escalation before other adjustments
44    apply_slo_escalation(&mut policy, requirements);
45
46    let has_429 = report.status_histogram.get(&429).copied().unwrap_or(0) > 0;
47    if has_429 {
48        policy.rate_limit_rps = policy.rate_limit_rps.min(2.0);
49        policy.max_retries = policy.max_retries.max(3);
50        policy.backoff_base_ms = policy.backoff_base_ms.max(500);
51        policy
52            .config_hints
53            .insert("retry.respect_429".to_string(), "true".to_string());
54    }
55
56    if report.blocked_requests > 0 {
57        policy.telemetry_level = TelemetryLevel::Deep;
58        policy.config_hints.insert(
59            "charon.capture_block_template".to_string(),
60            "true".to_string(),
61        );
62    }
63
64    // Clamp and enrich risk score with provider confidence and blocked ratio.
65    let provider_weight = match requirements.provider {
66        AntiBotProvider::Unknown => 0.1,
67        AntiBotProvider::Cloudflare => 0.25,
68        AntiBotProvider::DataDome => 0.35,
69        AntiBotProvider::Akamai
70        | AntiBotProvider::PerimeterX
71        | AntiBotProvider::Kasada
72        | AntiBotProvider::FingerprintCom => 0.3,
73    };
74
75    let mut risk = blocked_ratio * 0.7 + requirements.confidence * provider_weight;
76    if has_429 {
77        risk += 0.1;
78    }
79    policy.risk_score = risk.clamp(0.0, 1.0);
80
81    policy
82}
83
84/// Perform HAR investigation, infer requirements, and produce a runtime policy in one call.
85///
86/// # Errors
87///
88/// Returns [`har::HarError`] when the HAR payload is invalid or malformed.
89pub fn analyze_and_plan(har_json: &str) -> Result<InvestigationBundle, har::HarError> {
90    let report = investigate_har(har_json)?;
91    Ok(plan_from_report(report))
92}
93
94/// Infer requirements and build a runtime policy from an existing investigation report.
95#[must_use]
96pub fn plan_from_report(report: InvestigationReport) -> InvestigationBundle {
97    let requirements = infer_requirements(&report);
98    let policy = build_runtime_policy(&report, &requirements);
99
100    InvestigationBundle {
101        report,
102        requirements,
103        policy,
104    }
105}
106
107fn apply_strategy_defaults(policy: &mut RuntimePolicy, recommendation: &IntegrationRecommendation) {
108    match recommendation.strategy {
109        AdapterStrategy::DirectHttp => {
110            policy.execution_mode = ExecutionMode::Http;
111            policy.session_mode = SessionMode::Stateless;
112            policy.rate_limit_rps = 4.0;
113        }
114        AdapterStrategy::BrowserStealth => {
115            policy.execution_mode = ExecutionMode::Browser;
116            policy.session_mode = SessionMode::Stateless;
117            policy.rate_limit_rps = 2.0;
118            policy.max_retries = 3;
119            policy.enable_warmup = true;
120            policy.enforce_webrtc_proxy_only = true;
121        }
122        AdapterStrategy::StickyProxy => {
123            policy.execution_mode = ExecutionMode::Browser;
124            policy.session_mode = SessionMode::Sticky;
125            policy.rate_limit_rps = 1.5;
126            policy.max_retries = 3;
127            policy.backoff_base_ms = 600;
128            policy.enable_warmup = true;
129            policy.enforce_webrtc_proxy_only = true;
130            policy.sticky_session_ttl_secs = Some(600);
131            policy
132                .required_stygian_features
133                .push("stygian-proxy".to_string());
134            policy
135                .config_hints
136                .insert("proxy.rotation".to_string(), "per-domain".to_string());
137        }
138        AdapterStrategy::SessionWarmup => {
139            policy.execution_mode = ExecutionMode::Browser;
140            policy.enable_warmup = true;
141            policy.rate_limit_rps = 2.0;
142            policy.max_retries = 2;
143        }
144        AdapterStrategy::InvestigateOnly => {
145            policy.execution_mode = ExecutionMode::Http;
146            policy.telemetry_level = TelemetryLevel::Deep;
147            policy.rate_limit_rps = 2.0;
148            policy.max_retries = 1;
149            policy
150                .config_hints
151                .insert("charon.mode".to_string(), "investigation".to_string());
152        }
153    }
154
155    dedupe_required_features(&mut policy.required_stygian_features);
156}
157
158/// Apply SLO-based escalation to the runtime policy.
159///
160/// When the `adaptive_rate_and_retry_budget` requirement is in the warning zone (Medium level),
161/// increases retry budget and enables warmup for improved resilience.
162/// When in the critical zone (High level), escalates to browser mode with sticky sessions
163/// for maximum anti-bot posture.
164fn apply_slo_escalation(policy: &mut RuntimePolicy, requirements: &RequirementsProfile) {
165    // Find the adaptive_rate_and_retry_budget requirement
166    let adaptive_req = requirements
167        .requirements
168        .iter()
169        .find(|r| r.id == "adaptive_rate_and_retry_budget");
170
171    if let Some(req) = adaptive_req {
172        match req.level {
173            RequirementLevel::Medium => {
174                // Warning zone: increase retries and enable warmup for resilience
175                policy.max_retries = policy.max_retries.max(4);
176                policy.backoff_base_ms = policy.backoff_base_ms.max(400);
177                if policy.execution_mode == ExecutionMode::Http {
178                    policy.enable_warmup = true;
179                    policy
180                        .config_hints
181                        .insert("slo.escalation".to_string(), "warning".to_string());
182                }
183            }
184            RequirementLevel::High => {
185                // Critical zone: escalate to browser mode with sticky sessions if not already
186                if policy.execution_mode != ExecutionMode::Browser
187                    || policy.session_mode != SessionMode::Sticky
188                {
189                    policy.execution_mode = ExecutionMode::Browser;
190                    policy.session_mode = SessionMode::Sticky;
191                    policy.rate_limit_rps = policy.rate_limit_rps.min(1.5);
192                    policy.max_retries = policy.max_retries.max(5);
193                    policy.backoff_base_ms = policy.backoff_base_ms.max(600);
194                    policy.enable_warmup = true;
195                    policy.enforce_webrtc_proxy_only = true;
196                    policy.sticky_session_ttl_secs = Some(600);
197                    policy
198                        .required_stygian_features
199                        .push("stygian-proxy".to_string());
200                    policy
201                        .config_hints
202                        .insert("slo.escalation".to_string(), "critical".to_string());
203                    dedupe_required_features(&mut policy.required_stygian_features);
204                }
205            }
206            RequirementLevel::Low => {
207                // Below threshold: no escalation needed
208            }
209        }
210    }
211}
212
213fn dedupe_required_features(features: &mut Vec<String>) {
214    let mut seen = BTreeMap::new();
215    features.retain(|feature| seen.insert(feature.clone(), true).is_none());
216}
217
218#[allow(clippy::cast_precision_loss)]
219const fn to_f64(value: u64) -> f64 {
220    value as f64
221}
222
223#[cfg(test)]
224mod tests {
225    use std::collections::BTreeMap;
226
227    use crate::types::{Detection, InvestigationReport, MarkerCount, RequirementsProfile};
228
229    use super::*;
230
231    #[test]
232    fn sticky_proxy_strategy_enables_sticky_policy() {
233        let report = InvestigationReport {
234            page_title: Some("https://example.com".to_string()),
235            total_requests: 100,
236            blocked_requests: 30,
237            status_histogram: BTreeMap::from([(403, 30)]),
238            resource_type_histogram: BTreeMap::new(),
239            provider_histogram: BTreeMap::new(),
240            marker_histogram: BTreeMap::from([("x-datadome".to_string(), 30)]),
241            top_markers: vec![MarkerCount {
242                marker: "x-datadome".to_string(),
243                count: 30,
244            }],
245            hosts: Vec::new(),
246            suspicious_requests: Vec::new(),
247            aggregate: Detection {
248                provider: AntiBotProvider::DataDome,
249                confidence: 0.9,
250                markers: vec!["x-datadome".to_string()],
251            },
252            target_class: None,
253        };
254
255        let requirements = RequirementsProfile {
256            provider: AntiBotProvider::DataDome,
257            confidence: 0.9,
258            requirements: Vec::new(),
259            recommendation: IntegrationRecommendation {
260                strategy: AdapterStrategy::StickyProxy,
261                rationale: "test".to_string(),
262                required_stygian_features: vec!["stygian-browser".to_string()],
263                config_hints: BTreeMap::new(),
264            },
265        };
266
267        let policy = build_runtime_policy(&report, &requirements);
268        assert_eq!(policy.execution_mode, ExecutionMode::Browser);
269        assert_eq!(policy.session_mode, SessionMode::Sticky);
270        assert!(policy.sticky_session_ttl_secs.is_some());
271        assert!(policy.risk_score > 0.0);
272    }
273
274    #[test]
275    fn plan_from_report_preserves_report_and_builds_bundle() {
276        let report = InvestigationReport {
277            page_title: Some("https://example.com".to_string()),
278            total_requests: 10,
279            blocked_requests: 4,
280            status_histogram: BTreeMap::from([(200, 6), (403, 4)]),
281            resource_type_histogram: BTreeMap::from([("document".to_string(), 10)]),
282            provider_histogram: BTreeMap::from([(AntiBotProvider::Cloudflare, 4)]),
283            marker_histogram: BTreeMap::from([
284                ("cf-ray".to_string(), 4),
285                ("__cf_bm".to_string(), 4),
286            ]),
287            top_markers: vec![
288                MarkerCount {
289                    marker: "cf-ray".to_string(),
290                    count: 4,
291                },
292                MarkerCount {
293                    marker: "__cf_bm".to_string(),
294                    count: 4,
295                },
296            ],
297            hosts: Vec::new(),
298            suspicious_requests: Vec::new(),
299            aggregate: Detection {
300                provider: AntiBotProvider::Cloudflare,
301                confidence: 0.8,
302                markers: vec!["cf-ray".to_string(), "__cf_bm".to_string()],
303            },
304            target_class: None,
305        };
306
307        let bundle = plan_from_report(report.clone());
308        assert_eq!(bundle.report, report);
309        assert_eq!(bundle.requirements.provider, AntiBotProvider::Cloudflare);
310        assert_eq!(bundle.policy.execution_mode, ExecutionMode::Browser);
311    }
312
313    #[test]
314    fn slo_escalation_warning_increases_retries_and_warmup() {
315        // A requirement at Medium level (warning zone) should increase retries and enable warmup
316        use crate::types::{AntiBotRequirement, RequirementLevel};
317
318        let report = InvestigationReport {
319            page_title: None,
320            total_requests: 100,
321            blocked_requests: 20,
322            status_histogram: BTreeMap::new(),
323            resource_type_histogram: BTreeMap::new(),
324            provider_histogram: BTreeMap::new(),
325            marker_histogram: BTreeMap::new(),
326            top_markers: Vec::new(),
327            hosts: Vec::new(),
328            suspicious_requests: Vec::new(),
329            aggregate: Detection {
330                provider: AntiBotProvider::Unknown,
331                confidence: 0.0,
332                markers: Vec::new(),
333            },
334            target_class: None,
335        };
336
337        let requirements = RequirementsProfile {
338            provider: AntiBotProvider::Unknown,
339            confidence: 0.0,
340            requirements: vec![AntiBotRequirement {
341                id: "adaptive_rate_and_retry_budget".to_string(),
342                title: "Apply adaptive pacing".to_string(),
343                why: "Block ratio in warning zone".to_string(),
344                evidence: vec!["ratio=0.20".to_string()],
345                level: RequirementLevel::Medium,
346            }],
347            recommendation: IntegrationRecommendation {
348                strategy: AdapterStrategy::DirectHttp,
349                rationale: "test".to_string(),
350                required_stygian_features: Vec::new(),
351                config_hints: BTreeMap::new(),
352            },
353        };
354
355        let policy = build_runtime_policy(&report, &requirements);
356        assert!(policy.max_retries >= 4);
357        assert!(policy.backoff_base_ms >= 400);
358        assert!(policy.enable_warmup);
359        assert_eq!(
360            policy.config_hints.get("slo.escalation"),
361            Some(&"warning".to_string())
362        );
363    }
364
365    #[test]
366    fn slo_escalation_critical_escalates_to_browser_sticky() {
367        // A requirement at High level (critical zone) should escalate to browser + sticky session
368        use crate::types::{AntiBotRequirement, RequirementLevel};
369
370        let report = InvestigationReport {
371            page_title: None,
372            total_requests: 100,
373            blocked_requests: 25,
374            status_histogram: BTreeMap::new(),
375            resource_type_histogram: BTreeMap::new(),
376            provider_histogram: BTreeMap::new(),
377            marker_histogram: BTreeMap::new(),
378            top_markers: Vec::new(),
379            hosts: Vec::new(),
380            suspicious_requests: Vec::new(),
381            aggregate: Detection {
382                provider: AntiBotProvider::Unknown,
383                confidence: 0.0,
384                markers: Vec::new(),
385            },
386            target_class: None,
387        };
388
389        let requirements = RequirementsProfile {
390            provider: AntiBotProvider::Unknown,
391            confidence: 0.0,
392            requirements: vec![AntiBotRequirement {
393                id: "adaptive_rate_and_retry_budget".to_string(),
394                title: "Apply adaptive pacing".to_string(),
395                why: "Block ratio in critical zone".to_string(),
396                evidence: vec!["ratio=0.25".to_string()],
397                level: RequirementLevel::High,
398            }],
399            recommendation: IntegrationRecommendation {
400                strategy: AdapterStrategy::DirectHttp,
401                rationale: "test".to_string(),
402                required_stygian_features: Vec::new(),
403                config_hints: BTreeMap::new(),
404            },
405        };
406
407        let policy = build_runtime_policy(&report, &requirements);
408        assert_eq!(policy.execution_mode, ExecutionMode::Browser);
409        assert_eq!(policy.session_mode, SessionMode::Sticky);
410        assert!(policy.max_retries >= 5);
411        assert!(policy.backoff_base_ms >= 600);
412        assert!(policy.enable_warmup);
413        assert!(policy.sticky_session_ttl_secs.is_some());
414        assert!(
415            policy
416                .required_stygian_features
417                .contains(&"stygian-proxy".to_string())
418        );
419        assert_eq!(
420            policy.config_hints.get("slo.escalation"),
421            Some(&"critical".to_string())
422        );
423    }
424
425    #[test]
426    fn slo_escalation_respects_already_escalated_policies() {
427        // If already in browser/sticky, critical escalation should not downgrade
428        use crate::types::{AntiBotRequirement, RequirementLevel};
429
430        let report = InvestigationReport {
431            page_title: None,
432            total_requests: 100,
433            blocked_requests: 25,
434            status_histogram: BTreeMap::new(),
435            resource_type_histogram: BTreeMap::new(),
436            provider_histogram: BTreeMap::new(),
437            marker_histogram: BTreeMap::new(),
438            top_markers: Vec::new(),
439            hosts: Vec::new(),
440            suspicious_requests: Vec::new(),
441            aggregate: Detection {
442                provider: AntiBotProvider::Unknown,
443                confidence: 0.0,
444                markers: Vec::new(),
445            },
446            target_class: None,
447        };
448
449        let requirements = RequirementsProfile {
450            provider: AntiBotProvider::Unknown,
451            confidence: 0.0,
452            requirements: vec![AntiBotRequirement {
453                id: "adaptive_rate_and_retry_budget".to_string(),
454                title: "Apply adaptive pacing".to_string(),
455                why: "Block ratio in critical zone".to_string(),
456                evidence: vec!["ratio=0.25".to_string()],
457                level: RequirementLevel::High,
458            }],
459            recommendation: IntegrationRecommendation {
460                strategy: AdapterStrategy::StickyProxy,
461                rationale: "test".to_string(),
462                required_stygian_features: Vec::new(),
463                config_hints: BTreeMap::new(),
464            },
465        };
466
467        let policy = build_runtime_policy(&report, &requirements);
468        assert_eq!(policy.execution_mode, ExecutionMode::Browser);
469        assert_eq!(policy.session_mode, SessionMode::Sticky);
470        // Should not add stygian-proxy twice
471        let proxy_count = policy
472            .required_stygian_features
473            .iter()
474            .filter(|f| f.as_str() == "stygian-proxy")
475            .count();
476        assert_eq!(proxy_count, 1);
477    }
478
479    #[test]
480    fn slo_escalation_no_requirement_means_no_escalation() {
481        // Without the adaptive requirement, no escalation should happen
482        let report = InvestigationReport {
483            page_title: None,
484            total_requests: 100,
485            blocked_requests: 3,
486            status_histogram: BTreeMap::new(),
487            resource_type_histogram: BTreeMap::new(),
488            provider_histogram: BTreeMap::new(),
489            marker_histogram: BTreeMap::new(),
490            top_markers: Vec::new(),
491            hosts: Vec::new(),
492            suspicious_requests: Vec::new(),
493            aggregate: Detection {
494                provider: AntiBotProvider::Unknown,
495                confidence: 0.0,
496                markers: Vec::new(),
497            },
498            target_class: None,
499        };
500
501        let requirements = RequirementsProfile {
502            provider: AntiBotProvider::Unknown,
503            confidence: 0.0,
504            requirements: Vec::new(), // No adaptive requirement
505            recommendation: IntegrationRecommendation {
506                strategy: AdapterStrategy::DirectHttp,
507                rationale: "test".to_string(),
508                required_stygian_features: Vec::new(),
509                config_hints: BTreeMap::new(),
510            },
511        };
512
513        let policy = build_runtime_policy(&report, &requirements);
514        assert_eq!(policy.execution_mode, ExecutionMode::Http);
515        assert_eq!(policy.session_mode, SessionMode::Stateless);
516        assert_eq!(policy.max_retries, 2);
517        assert!(!policy.enable_warmup);
518        assert_eq!(policy.config_hints.get("slo.escalation"), None);
519    }
520}