Skip to main content

assay_sim/attacks/
consumer_downgrade.rs

1//! Protocol Evidence Interpretation Attacks.
2//!
3//! Tests consumer-side trust downgrade under partial, ambiguous, or flattened
4//! protocol evidence. When does protocol-valid but incompletely interpreted
5//! metadata lead to an overly optimistic trust decision?
6//!
7//! 4 attack vectors + 3 benign controls. All deterministic, no LLM calls.
8
9use crate::report::{AttackResult, AttackStatus};
10use assay_core::mcp::decision::{
11    classify_replay_diff, required_consumer_fields_v1, ConsumerPayloadState, ConsumerReadPath,
12    Decision, DecisionOrigin, DecisionOutcomeKind, DenyClassificationSource,
13    FulfillmentDecisionPath, OutcomeCompatState, ReplayClassificationSource, ReplayDiffBasis,
14    ReplayDiffBucket, DECISION_BASIS_VERSION_V1, DECISION_CONSUMER_CONTRACT_VERSION_V1,
15    DENY_PRECEDENCE_VERSION_V1,
16};
17use serde::Serialize;
18use std::time::Instant;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct ConsumerResult {
22    pub vector_id: String,
23    pub condition: String,
24    pub realism_class: String,
25    pub canonical_classification: String,
26    pub consumer_classification: String,
27    pub downgrade_occurred: bool,
28    pub outcome: ConsumerOutcome,
29    pub hypothesis_tags: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum ConsumerOutcome {
35    NoEffect,
36    RetainedNoDowngrade,
37    DowngradeWithCorrectDetection,
38    SilentDowngrade,
39    SilentTrustUpgrade,
40}
41
42fn make_converged_deny_basis() -> ReplayDiffBasis {
43    ReplayDiffBasis {
44        decision_outcome_kind: Some(DecisionOutcomeKind::PolicyDeny),
45        decision_origin: Some(DecisionOrigin::PolicyEngine),
46        outcome_compat_state: Some(OutcomeCompatState::LegacyFieldsPreserved),
47        fulfillment_decision_path: Some(FulfillmentDecisionPath::PolicyDeny),
48        decision_basis_version: DECISION_BASIS_VERSION_V1.to_string(),
49        compat_fallback_applied: false,
50        classification_source: ReplayClassificationSource::ConvergedOutcome,
51        replay_diff_reason: "converged_policy_deny".to_string(),
52        legacy_shape_detected: false,
53        decision_consumer_contract_version: DECISION_CONSUMER_CONTRACT_VERSION_V1.to_string(),
54        consumer_read_path: ConsumerReadPath::ConvergedDecision,
55        consumer_fallback_applied: false,
56        consumer_payload_state: ConsumerPayloadState::Converged,
57        required_consumer_fields: required_consumer_fields_v1(),
58        policy_deny: true,
59        fail_closed_deny: false,
60        enforcement_deny: false,
61        deny_precedence_version: DENY_PRECEDENCE_VERSION_V1.to_string(),
62        deny_classification_source: DenyClassificationSource::OutcomeKind,
63        deny_legacy_fallback_applied: false,
64        deny_convergence_reason: "outcome_policy_deny".to_string(),
65        reason_code: "policy_deny_sensitive_tool".to_string(),
66        typed_decision: None,
67        policy_version: Some("v1".to_string()),
68        policy_digest: Some("sha256:abc".to_string()),
69        decision: Decision::Allow, // legacy field diverges from converged
70        fail_closed_applied: false,
71    }
72}
73
74fn make_converged_allow_basis() -> ReplayDiffBasis {
75    ReplayDiffBasis {
76        decision_outcome_kind: Some(DecisionOutcomeKind::ObligationApplied),
77        decision_origin: Some(DecisionOrigin::PolicyEngine),
78        outcome_compat_state: Some(OutcomeCompatState::LegacyFieldsPreserved),
79        fulfillment_decision_path: Some(FulfillmentDecisionPath::PolicyAllow),
80        decision_basis_version: DECISION_BASIS_VERSION_V1.to_string(),
81        compat_fallback_applied: false,
82        classification_source: ReplayClassificationSource::ConvergedOutcome,
83        replay_diff_reason: "converged_obligation_applied".to_string(),
84        legacy_shape_detected: false,
85        decision_consumer_contract_version: DECISION_CONSUMER_CONTRACT_VERSION_V1.to_string(),
86        consumer_read_path: ConsumerReadPath::ConvergedDecision,
87        consumer_fallback_applied: false,
88        consumer_payload_state: ConsumerPayloadState::Converged,
89        required_consumer_fields: required_consumer_fields_v1(),
90        policy_deny: false,
91        fail_closed_deny: false,
92        enforcement_deny: false,
93        deny_precedence_version: DENY_PRECEDENCE_VERSION_V1.to_string(),
94        deny_classification_source: DenyClassificationSource::OutcomeKind,
95        deny_legacy_fallback_applied: false,
96        deny_convergence_reason: "outcome_not_deny".to_string(),
97        reason_code: "obligation_applied_log".to_string(),
98        typed_decision: None,
99        policy_version: Some("v1".to_string()),
100        policy_digest: Some("sha256:abc".to_string()),
101        decision: Decision::Allow,
102        fail_closed_applied: false,
103    }
104}
105
106/// Canonical restrictiveness: deny variants = 2, allow/obligation = 1.
107fn canonical_rank(basis: &ReplayDiffBasis) -> u8 {
108    match basis.decision_outcome_kind {
109        Some(DecisionOutcomeKind::PolicyDeny)
110        | Some(DecisionOutcomeKind::FailClosedDeny)
111        | Some(DecisionOutcomeKind::EnforcementDeny) => 2,
112        _ => 1,
113    }
114}
115
116/// Partial consumer rank: reads only legacy `decision` field.
117fn legacy_only_rank(basis: &ReplayDiffBasis) -> u8 {
118    match basis.decision {
119        Decision::Deny | Decision::Error => 2,
120        Decision::Allow => 1,
121    }
122}
123
124// ---------------------------------------------------------------------------
125// Vector 1: Partial-Field Trust Read
126// ---------------------------------------------------------------------------
127
128pub fn vector1_partial_trust_read(condition: &str) -> (ConsumerResult, AttackResult) {
129    let start = Instant::now();
130    let basis = make_converged_deny_basis();
131
132    let canonical = canonical_rank(&basis); // 2 (deny via decision_outcome_kind)
133    let consumer = match condition {
134        "condition_a" => legacy_only_rank(&basis), // 1 (Allow via legacy decision)
135        "condition_b" | "condition_c" => canonical_rank(&basis), // follows read-path
136        _ => legacy_only_rank(&basis),
137    };
138
139    let downgrade = consumer < canonical;
140    let outcome = if !downgrade {
141        ConsumerOutcome::NoEffect
142    } else {
143        match condition {
144            "condition_b" | "condition_c" => ConsumerOutcome::DowngradeWithCorrectDetection,
145            _ => ConsumerOutcome::SilentDowngrade,
146        }
147    };
148
149    make_consumer_result(
150        "v1_partial_trust_read",
151        condition,
152        "consumer_realistic_synthetic",
153        &format!("rank_{}", canonical),
154        &format!("rank_{}", consumer),
155        downgrade,
156        outcome,
157        vec!["H1".into()],
158        start,
159    )
160}
161
162// ---------------------------------------------------------------------------
163// Vector 2: Precedence Inversion (Deny Convergence)
164// ---------------------------------------------------------------------------
165
166pub fn vector2_precedence_inversion(condition: &str) -> (ConsumerResult, AttackResult) {
167    let start = Instant::now();
168
169    let mut basis = make_converged_deny_basis();
170    basis.decision_outcome_kind = Some(DecisionOutcomeKind::EnforcementDeny);
171    basis.enforcement_deny = false; // legacy field not normalized
172    basis.policy_deny = false;
173    basis.deny_classification_source = DenyClassificationSource::LegacyDecision;
174    basis.decision = Decision::Allow;
175
176    // Canonical: tier-1 decision_outcome_kind = EnforcementDeny → deny
177    let canonical_is_deny = true;
178
179    // Consumer behavior per condition
180    let consumer_is_deny = match condition {
181        "condition_a" => basis.decision == Decision::Deny, // reads legacy: false
182        "condition_b" => {
183            // Reads converged fields but wrong precedence: checks policy_deny first
184            basis.policy_deny || basis.enforcement_deny
185        }
186        "condition_c" => {
187            // Full hardening: tier-1 decision_outcome_kind wins
188            matches!(
189                basis.decision_outcome_kind,
190                Some(DecisionOutcomeKind::PolicyDeny)
191                    | Some(DecisionOutcomeKind::FailClosedDeny)
192                    | Some(DecisionOutcomeKind::EnforcementDeny)
193            )
194        }
195        _ => false,
196    };
197
198    let downgrade = canonical_is_deny && !consumer_is_deny;
199    let outcome = if !downgrade {
200        ConsumerOutcome::NoEffect
201    } else {
202        match condition {
203            "condition_c" => ConsumerOutcome::DowngradeWithCorrectDetection,
204            _ => ConsumerOutcome::SilentDowngrade,
205        }
206    };
207
208    make_consumer_result(
209        "v2_precedence_inversion",
210        condition,
211        "producer_realistic",
212        "deny",
213        if consumer_is_deny { "deny" } else { "not_deny" },
214        downgrade,
215        outcome,
216        vec!["H1".into()],
217        start,
218    )
219}
220
221// ---------------------------------------------------------------------------
222// Vector 3: Compat Flattening (Trust Signal Suppression)
223// ---------------------------------------------------------------------------
224
225pub fn vector3_compat_flattening(condition: &str) -> (ConsumerResult, AttackResult) {
226    let start = Instant::now();
227
228    let mut compat_basis = make_converged_allow_basis();
229    compat_basis.consumer_payload_state = ConsumerPayloadState::CompatibilityFallback;
230    compat_basis.consumer_fallback_applied = true;
231    compat_basis.consumer_read_path = ConsumerReadPath::CompatibilityMarkers;
232    compat_basis.compat_fallback_applied = true;
233
234    let converged_basis = make_converged_allow_basis();
235
236    // Canonical: these two are different (compat vs converged)
237    let canonical_bucket = classify_replay_diff(&converged_basis, &compat_basis);
238
239    // Consumer that flattens compat: treats compat_basis as if it were converged
240    let mut flattened = compat_basis.clone();
241    let consumer_bucket = match condition {
242        "condition_a" => {
243            // Ignores all compat signals, reads as converged
244            flattened.consumer_payload_state = ConsumerPayloadState::Converged;
245            flattened.consumer_fallback_applied = false;
246            flattened.consumer_read_path = ConsumerReadPath::ConvergedDecision;
247            flattened.compat_fallback_applied = false;
248            classify_replay_diff(&converged_basis, &flattened)
249        }
250        "condition_b" => {
251            // Follows read-path but treats compat as non-binding
252            flattened.consumer_payload_state = ConsumerPayloadState::Converged;
253            flattened.consumer_fallback_applied = false;
254            flattened.compat_fallback_applied = false;
255            classify_replay_diff(&converged_basis, &flattened)
256        }
257        "condition_c" => {
258            // Full hardening: compat signals are binding
259            classify_replay_diff(&converged_basis, &compat_basis)
260        }
261        _ => classify_replay_diff(&converged_basis, &flattened),
262    };
263
264    let downgrade =
265        canonical_bucket != consumer_bucket && consumer_bucket == ReplayDiffBucket::Unchanged;
266
267    let outcome = if canonical_bucket == consumer_bucket || condition == "condition_c" {
268        ConsumerOutcome::NoEffect
269    } else if consumer_bucket == ReplayDiffBucket::Unchanged {
270        ConsumerOutcome::SilentDowngrade
271    } else {
272        ConsumerOutcome::RetainedNoDowngrade
273    };
274
275    make_consumer_result(
276        "v3_compat_flattening",
277        condition,
278        "consumer_realistic",
279        &format!("{:?}", canonical_bucket),
280        &format!("{:?}", consumer_bucket),
281        downgrade,
282        outcome,
283        vec!["H4".into()],
284        start,
285    )
286}
287
288// ---------------------------------------------------------------------------
289// Vector 4: Projection Loss (Required Fields Dropped)
290// ---------------------------------------------------------------------------
291
292pub fn vector4_projection_loss(condition: &str) -> (ConsumerResult, AttackResult) {
293    let start = Instant::now();
294
295    let full = make_converged_deny_basis();
296    let full_rank = canonical_rank(&full); // 2
297
298    // Strip all converged + compat fields: simulate forwarding chain loss
299    let mut stripped = full.clone();
300    stripped.decision_outcome_kind = None;
301    stripped.decision_origin = None;
302    stripped.fulfillment_decision_path = None;
303    stripped.decision_basis_version = String::new();
304    stripped.compat_fallback_applied = false;
305    stripped.classification_source = ReplayClassificationSource::LegacyFallback;
306    stripped.legacy_shape_detected = false;
307    stripped.consumer_read_path = ConsumerReadPath::LegacyDecision;
308    stripped.consumer_payload_state = ConsumerPayloadState::LegacyBase;
309    stripped.consumer_fallback_applied = true;
310
311    let stripped_rank = legacy_only_rank(&stripped); // 1 (Allow)
312
313    let consumer_rank = match condition {
314        "condition_a" | "condition_b" => stripped_rank,
315        "condition_c" => {
316            // Full hardening: required-field completeness check detects drop
317            // Falls back to canonical interpretation
318            full_rank
319        }
320        _ => stripped_rank,
321    };
322
323    let downgrade = consumer_rank < full_rank;
324    let outcome = if !downgrade {
325        if condition == "condition_c" {
326            ConsumerOutcome::DowngradeWithCorrectDetection
327        } else {
328            ConsumerOutcome::NoEffect
329        }
330    } else {
331        ConsumerOutcome::SilentDowngrade
332    };
333
334    make_consumer_result(
335        "v4_projection_loss",
336        condition,
337        "adapter_realistic",
338        &format!("rank_{}", full_rank),
339        &format!("rank_{}", consumer_rank),
340        downgrade,
341        outcome,
342        vec!["H2".into()],
343        start,
344    )
345}
346
347// ---------------------------------------------------------------------------
348// Benign Controls
349// ---------------------------------------------------------------------------
350
351pub fn control_e1_legitimate_legacy(condition: &str) -> (ConsumerResult, AttackResult) {
352    let start = Instant::now();
353    // Genuine legacy payload: only decision field, no converged markers
354    let mut basis = make_converged_allow_basis();
355    basis.decision_outcome_kind = None;
356    basis.decision_origin = None;
357    basis.fulfillment_decision_path = None;
358    basis.consumer_read_path = ConsumerReadPath::LegacyDecision;
359    basis.consumer_payload_state = ConsumerPayloadState::LegacyBase;
360
361    let rank = legacy_only_rank(&basis);
362    let canonical = rank; // for legacy, legacy IS canonical
363
364    make_consumer_result(
365        "control_e1_legacy",
366        condition,
367        "producer_realistic",
368        &format!("rank_{}", canonical),
369        &format!("rank_{}", rank),
370        false,
371        ConsumerOutcome::NoEffect,
372        vec!["H3".into()],
373        start,
374    )
375}
376
377pub fn control_e2_legitimate_compat(condition: &str) -> (ConsumerResult, AttackResult) {
378    let start = Instant::now();
379    let mut basis = make_converged_allow_basis();
380    basis.consumer_payload_state = ConsumerPayloadState::CompatibilityFallback;
381    basis.consumer_fallback_applied = true;
382    basis.consumer_read_path = ConsumerReadPath::CompatibilityMarkers;
383
384    // Legitimate compat: classification should be correct
385    let canonical = canonical_rank(&basis);
386    let consumer = canonical_rank(&basis);
387
388    make_consumer_result(
389        "control_e2_compat",
390        condition,
391        "producer_realistic",
392        &format!("rank_{}", canonical),
393        &format!("rank_{}", consumer),
394        false,
395        ConsumerOutcome::NoEffect,
396        vec!["H3".into()],
397        start,
398    )
399}
400
401pub fn control_e3_legitimate_converged(condition: &str) -> (ConsumerResult, AttackResult) {
402    let start = Instant::now();
403    let basis = make_converged_allow_basis();
404    let canonical = canonical_rank(&basis);
405    let consumer = canonical_rank(&basis);
406
407    make_consumer_result(
408        "control_e3_converged",
409        condition,
410        "producer_realistic",
411        &format!("rank_{}", canonical),
412        &format!("rank_{}", consumer),
413        false,
414        ConsumerOutcome::NoEffect,
415        vec!["H3".into()],
416        start,
417    )
418}
419
420// ---------------------------------------------------------------------------
421// Full matrix runner
422// ---------------------------------------------------------------------------
423
424pub fn run_consumer_downgrade_matrix() -> (Vec<ConsumerResult>, Vec<AttackResult>) {
425    let mut results = Vec::new();
426    let mut attacks = Vec::new();
427
428    for condition in ["condition_a", "condition_b", "condition_c"] {
429        for vector_fn in [
430            vector1_partial_trust_read,
431            vector2_precedence_inversion,
432            vector3_compat_flattening,
433            vector4_projection_loss,
434        ] {
435            let (cr, ar) = vector_fn(condition);
436            results.push(cr);
437            attacks.push(ar);
438        }
439
440        for control_fn in [
441            control_e1_legitimate_legacy,
442            control_e2_legitimate_compat,
443            control_e3_legitimate_converged,
444        ] {
445            let (cr, ar) = control_fn(condition);
446            results.push(cr);
447            attacks.push(ar);
448        }
449    }
450
451    (results, attacks)
452}
453
454#[allow(clippy::too_many_arguments)]
455fn make_consumer_result(
456    vector: &str,
457    condition: &str,
458    realism: &str,
459    canonical: &str,
460    consumer: &str,
461    downgrade: bool,
462    outcome: ConsumerOutcome,
463    tags: Vec<String>,
464    start: Instant,
465) -> (ConsumerResult, AttackResult) {
466    let cr = ConsumerResult {
467        vector_id: vector.to_string(),
468        condition: condition.to_string(),
469        realism_class: realism.to_string(),
470        canonical_classification: canonical.to_string(),
471        consumer_classification: consumer.to_string(),
472        downgrade_occurred: downgrade,
473        outcome: outcome.clone(),
474        hypothesis_tags: tags,
475    };
476    let status = match &outcome {
477        ConsumerOutcome::SilentDowngrade | ConsumerOutcome::SilentTrustUpgrade => {
478            AttackStatus::Bypassed
479        }
480        ConsumerOutcome::DowngradeWithCorrectDetection => AttackStatus::Blocked,
481        _ => AttackStatus::Passed,
482    };
483    let ar = AttackResult {
484        name: format!("consumer.{}.{}", vector, condition),
485        status,
486        error_class: None,
487        error_code: None,
488        message: Some(format!(
489            "canonical={} consumer={} downgrade={} outcome={:?}",
490            canonical, consumer, downgrade, outcome
491        )),
492        duration_ms: start.elapsed().as_millis() as u64,
493    };
494    (cr, ar)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn v1_downgrades_under_a() {
503        let (cr, _) = vector1_partial_trust_read("condition_a");
504        assert_eq!(cr.outcome, ConsumerOutcome::SilentDowngrade);
505    }
506
507    #[test]
508    fn v1_blocked_under_b() {
509        let (cr, _) = vector1_partial_trust_read("condition_b");
510        assert_eq!(cr.outcome, ConsumerOutcome::NoEffect);
511    }
512
513    #[test]
514    fn v2_downgrades_under_a_and_b() {
515        let (cr_a, _) = vector2_precedence_inversion("condition_a");
516        let (cr_b, _) = vector2_precedence_inversion("condition_b");
517        assert_eq!(cr_a.outcome, ConsumerOutcome::SilentDowngrade);
518        assert_eq!(cr_b.outcome, ConsumerOutcome::SilentDowngrade);
519    }
520
521    #[test]
522    fn v2_blocked_under_c() {
523        let (cr, _) = vector2_precedence_inversion("condition_c");
524        assert_eq!(cr.outcome, ConsumerOutcome::NoEffect);
525    }
526
527    #[test]
528    fn v3_flattens_under_a() {
529        let (cr_a, _) = vector3_compat_flattening("condition_a");
530        assert_ne!(
531            cr_a.outcome,
532            ConsumerOutcome::NoEffect,
533            "V3 under A should show some effect, got {:?}",
534            cr_a.outcome
535        );
536    }
537
538    #[test]
539    fn v3_flattens_or_detected_under_b() {
540        let (cr_b, _) = vector3_compat_flattening("condition_b");
541        // B treats compat as non-binding — flattening may produce different bucket
542        // or may collapse to same if classify_replay_diff fields happen to match
543        assert_ne!(
544            cr_b.outcome,
545            ConsumerOutcome::SilentTrustUpgrade,
546            "V3 under B should not produce trust upgrade"
547        );
548    }
549
550    #[test]
551    fn v3_clean_under_c() {
552        let (cr, _) = vector3_compat_flattening("condition_c");
553        assert_eq!(cr.outcome, ConsumerOutcome::NoEffect);
554    }
555
556    #[test]
557    fn v4_downgrades_under_a_and_b() {
558        let (cr_a, _) = vector4_projection_loss("condition_a");
559        let (cr_b, _) = vector4_projection_loss("condition_b");
560        assert_eq!(cr_a.outcome, ConsumerOutcome::SilentDowngrade);
561        assert_eq!(cr_b.outcome, ConsumerOutcome::SilentDowngrade);
562    }
563
564    #[test]
565    fn v4_detected_under_c() {
566        let (cr, _) = vector4_projection_loss("condition_c");
567        assert_eq!(cr.outcome, ConsumerOutcome::DowngradeWithCorrectDetection);
568    }
569
570    #[test]
571    fn controls_no_false_positives() {
572        for cond in ["condition_a", "condition_b", "condition_c"] {
573            let (e1, _) = control_e1_legitimate_legacy(cond);
574            let (e2, _) = control_e2_legitimate_compat(cond);
575            let (e3, _) = control_e3_legitimate_converged(cond);
576            assert_eq!(
577                e1.outcome,
578                ConsumerOutcome::NoEffect,
579                "E1 FP under {}",
580                cond
581            );
582            assert_eq!(
583                e2.outcome,
584                ConsumerOutcome::NoEffect,
585                "E2 FP under {}",
586                cond
587            );
588            assert_eq!(
589                e3.outcome,
590                ConsumerOutcome::NoEffect,
591                "E3 FP under {}",
592                cond
593            );
594        }
595    }
596
597    #[test]
598    fn full_matrix_structure() {
599        let (results, attacks) = run_consumer_downgrade_matrix();
600        assert_eq!(results.len(), 21); // 3 conditions * 7 (4 vectors + 3 controls)
601        assert_eq!(attacks.len(), 21);
602    }
603}