diff --git a/crates/aep-core/src/evidence.rs b/crates/aep-core/src/evidence.rs index c55f8c9..f366bb6 100644 --- a/crates/aep-core/src/evidence.rs +++ b/crates/aep-core/src/evidence.rs @@ -50,11 +50,12 @@ pub struct ActionEvidence { pub causal_chain_id: Option, pub recording_mode: RecordingMode, pub capability_decision: Option, - /// `McpHeaderRisk` variant name in `snake_case` when MCP header leakage is - /// detected, else `None`. Serializes as a lowercase string (e.g. - /// `"credential_leak"`) via `#[serde(rename_all = "snake_case")]` on the - /// enum, so downstream consumers need no Rust enum definition to read it. - pub mcp_header_risk: Option, + /// `McpHeaderRisk` variant name in `snake_case` (e.g. `credential_leak`) + /// when MCP header leakage is detected, else `None`. Stored as a plain + /// `String` so the serialized AEP record uses stable lowercase identifiers + /// and downstream consumers need no Rust enum definition to read it. + /// Producers convert from the enum via [`McpHeaderRisk::as_str`]. + pub mcp_header_risk: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,11 +103,46 @@ mod tests { causal_chain_id: None, recording_mode: RecordingMode::Full, capability_decision: None, - mcp_header_risk: Some(McpHeaderRisk::CredentialLeak), + mcp_header_risk: Some("credential_leak".into()), }; let value = serde_json::to_value(evidence).expect("serialize ActionEvidence"); assert_eq!(value["mcp_header_risk"], "credential_leak"); } + + #[test] + fn action_evidence_mcp_header_risk_round_trips_as_plain_string() { + // The field is `Option` carrying the snake_case variant name: + // prove it survives a serialize→deserialize round trip as a plain + // string (not an enum), so downstream consumers need no Rust enum + // definition to read it. Exercises the HighEntropyValue variant, which + // the serialize-only test above does not cover. + let original = ActionEvidence { + action_id: "action-rt".into(), + tool_name: "POST /mcp".into(), + state_changing: true, + precondition_digest: None, + result_digest: None, + timestamp_ms: 1_700_000_000_001, + parent_action_id: None, + causal_chain_id: None, + recording_mode: RecordingMode::Validation, + capability_decision: None, + mcp_header_risk: Some(McpHeaderRisk::HighEntropyValue.as_str().into()), + }; + + let json = serde_json::to_string(&original).expect("serialize ActionEvidence"); + assert!( + json.contains("\"mcp_header_risk\":\"high_entropy_value\""), + "expected snake_case variant name in JSON, got: {json}" + ); + + let decoded: ActionEvidence = + serde_json::from_str(&json).expect("deserialize ActionEvidence"); + assert_eq!( + decoded.mcp_header_risk.as_deref(), + Some(McpHeaderRisk::HighEntropyValue.as_str()) + ); + } } diff --git a/crates/proxy-wasm-evidence/src/recorder.rs b/crates/proxy-wasm-evidence/src/recorder.rs index 062e6f5..019d7ef 100644 --- a/crates/proxy-wasm-evidence/src/recorder.rs +++ b/crates/proxy-wasm-evidence/src/recorder.rs @@ -147,7 +147,7 @@ pub fn build_evidence( causal_chain_id: None, recording_mode: policy.mode, capability_decision: None, - mcp_header_risk, + mcp_header_risk: mcp_header_risk.map(|r| r.as_str().to_string()), } } @@ -334,7 +334,7 @@ mod tests { None, Some(McpHeaderRisk::CredentialLeak), ); - assert_eq!(ev.mcp_header_risk, Some(McpHeaderRisk::CredentialLeak)); + assert_eq!(ev.mcp_header_risk.as_deref(), Some("credential_leak")); let ev2 = build_evidence( "ctx-4".into(), @@ -344,7 +344,7 @@ mod tests { None, Some(McpHeaderRisk::PiiLeak), ); - assert_eq!(ev2.mcp_header_risk, Some(McpHeaderRisk::PiiLeak)); + assert_eq!(ev2.mcp_header_risk.as_deref(), Some("pii_leak")); } // --- EvidenceBuffer tests ---