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