1use 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, 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
106fn 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
116fn legacy_only_rank(basis: &ReplayDiffBasis) -> u8 {
118 match basis.decision {
119 Decision::Deny | Decision::Error => 2,
120 Decision::Allow => 1,
121 }
122}
123
124pub 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); let consumer = match condition {
134 "condition_a" => legacy_only_rank(&basis), "condition_b" | "condition_c" => canonical_rank(&basis), _ => 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
162pub 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; basis.policy_deny = false;
173 basis.deny_classification_source = DenyClassificationSource::LegacyDecision;
174 basis.decision = Decision::Allow;
175
176 let canonical_is_deny = true;
178
179 let consumer_is_deny = match condition {
181 "condition_a" => basis.decision == Decision::Deny, "condition_b" => {
183 basis.policy_deny || basis.enforcement_deny
185 }
186 "condition_c" => {
187 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
221pub 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 let canonical_bucket = classify_replay_diff(&converged_basis, &compat_basis);
238
239 let mut flattened = compat_basis.clone();
241 let consumer_bucket = match condition {
242 "condition_a" => {
243 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 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 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
288pub 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); 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); let consumer_rank = match condition {
314 "condition_a" | "condition_b" => stripped_rank,
315 "condition_c" => {
316 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
347pub fn control_e1_legitimate_legacy(condition: &str) -> (ConsumerResult, AttackResult) {
352 let start = Instant::now();
353 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; 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 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
420pub 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 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); assert_eq!(attacks.len(), 21);
602 }
603}