Skip to main content

vex_runtime/
gate.rs

1use async_trait::async_trait;
2use uuid::Uuid;
3use vex_core::audit::EvidenceCapsule;
4use vex_llm::Capability;
5
6/// Exogenous Gate Decision Boundary
7///
8/// A Gate acts as a continuation authority, deciding whether an agent's
9/// output is "Safe", "Valid", or "Audit-Compliant".
10#[async_trait]
11pub trait Gate: Send + Sync + std::fmt::Debug {
12    /// Evaluate the current execution state and return a signed Evidence Capsule.
13    async fn execute_gate(
14        &self,
15        agent_id: Uuid,
16        task_prompt: &str,
17        suggested_output: &str,
18        confidence: f64,
19        capabilities: Vec<Capability>,
20    ) -> EvidenceCapsule;
21}
22
23/// A mock implementation of the Generic Gate for testing and local development.
24#[derive(Debug, Default)]
25pub struct GenericGateMock;
26
27#[async_trait]
28impl Gate for GenericGateMock {
29    async fn execute_gate(
30        &self,
31        _agent_id: Uuid,
32        _task_prompt: &str,
33        suggested_output: &str,
34        confidence: f64,
35        capabilities: Vec<Capability>,
36    ) -> EvidenceCapsule {
37        // Simple logic for the mock:
38        // 1. If confidence is very low (< 0.3), HALT.
39        // 2. If the output contains common failure patterns, HALT.
40        // 3. Otherwise, ALLOW.
41
42        let (outcome, reason) = if confidence < 0.3 {
43            ("HALT", "LOW_CONFIDENCE")
44        } else if capabilities.contains(&Capability::Network)
45            && !suggested_output.to_lowercase().contains("http")
46        {
47            // Example policy: If you have network capability but don't explain the URL, caution.
48            ("ALLOW", "SENSORS_ORANGE_NETWORK_IDLE")
49        } else if suggested_output.to_lowercase().contains("i'm sorry")
50            || suggested_output.to_lowercase().contains("cannot fulfill")
51        {
52            ("HALT", "REFUSAL_FILTER")
53        } else {
54            ("ALLOW", "SENSORS_GREEN")
55        };
56
57        EvidenceCapsule {
58            capsule_id: format!("mock-{}", &Uuid::new_v4().to_string()[..8]),
59            outcome: outcome.to_string(),
60            reason_code: reason.to_string(),
61            witness_receipt: "mock-receipt-0xdeadbeef".to_string(),
62            nonce: 0,
63            sensors: serde_json::json!({
64                "confidence_sensor": if confidence > 0.5 { "GREEN" } else { "YELLOW" },
65                "content_length": suggested_output.len(),
66            }),
67            reproducibility_context: serde_json::json!({
68                "gate_provider": "ChoraGateMock",
69                "version": "0.1.0",
70            }),
71        }
72    }
73}
74
75/// Networked Gate provider communicating over HTTP
76#[derive(Debug)]
77pub struct HttpGate {
78    pub client: reqwest::Client,
79    pub url: String,
80    pub api_key: String,
81}
82
83impl HttpGate {
84    pub fn new(url: String, api_key: String) -> Self {
85        Self {
86            client: reqwest::Client::new(),
87            url,
88            api_key,
89        }
90    }
91}
92
93#[async_trait]
94impl Gate for HttpGate {
95    async fn execute_gate(
96        &self,
97        agent_id: Uuid,
98        task_prompt: &str,
99        suggested_output: &str,
100        confidence: f64,
101        capabilities: Vec<Capability>,
102    ) -> EvidenceCapsule {
103        // Note: The Vanguard gate only cares about confidence and capabilities.
104        // It does not need agent_id or task_prompt for the core policy check.
105        let payload = serde_json::json!({
106            "agent_id": agent_id,
107            "task_prompt": task_prompt,
108            "suggested_output": suggested_output,
109            "confidence": confidence,
110            "capabilities": capabilities.iter().map(|c| format!("{:?}", c)).collect::<Vec<String>>(),
111        });
112
113        let gate_url = if self.url.ends_with('/') {
114            format!("{}gate", self.url)
115        } else {
116            format!("{}/gate", self.url)
117        };
118
119        match self
120            .client
121            .post(&gate_url)
122            .header("x-api-key", &self.api_key)
123            .json(&payload)
124            .send()
125            .await
126        {
127            Ok(resp) => {
128                let status = resp.status();
129                if status.is_success() {
130                    let text = resp.text().await.unwrap_or_else(|_| "".to_string());
131
132                    // CHORA response shape:
133                    // {
134                    //   "signed_payload": { "capsule_id": "...", "outcome": "...", "reason_code": "..." },
135                    //   "witness_receipt": "...",   // optional
136                    //   "nonce": 0,                 // optional
137                    //   "sensors": {...},           // optional
138                    //   "reproducibility_context": {...} // optional
139                    // }
140                    #[derive(serde::Deserialize)]
141                    struct VanguardSignedPayload {
142                        capsule_id: String,
143                        outcome: String,
144                        reason_code: String,
145                        #[serde(default)]
146                        witness_receipt: Option<String>,
147                        #[serde(default)]
148                        nonce: Option<u64>,
149                    }
150                    #[derive(serde::Deserialize)]
151                    struct VanguardResponse {
152                        signed_payload: VanguardSignedPayload,
153                        #[serde(default)]
154                        witness_receipt: Option<String>,
155                        #[serde(default)]
156                        sensors: Option<serde_json::Value>,
157                        #[serde(default)]
158                        reproducibility_context: Option<serde_json::Value>,
159                    }
160
161                    match serde_json::from_str::<VanguardResponse>(&text) {
162                        Ok(v_resp) => {
163                            // witness_receipt: prefer top-level field, fall back to signed_payload field
164                            let witness = v_resp
165                                .witness_receipt
166                                .or(v_resp.signed_payload.witness_receipt)
167                                .unwrap_or_else(|| {
168                                    format!("chora-{}", v_resp.signed_payload.capsule_id)
169                                });
170                            EvidenceCapsule {
171                                capsule_id: v_resp.signed_payload.capsule_id,
172                                outcome: v_resp.signed_payload.outcome,
173                                reason_code: v_resp.signed_payload.reason_code,
174                                witness_receipt: witness,
175                                nonce: v_resp.signed_payload.nonce.unwrap_or(0),
176                                sensors: v_resp.sensors.unwrap_or(serde_json::Value::Null),
177                                reproducibility_context: v_resp
178                                    .reproducibility_context
179                                    .unwrap_or(serde_json::Value::Null),
180                            }
181                        }
182                        Err(e) => EvidenceCapsule {
183                            capsule_id: "error".to_string(),
184                            outcome: "HALT".to_string(),
185                            reason_code: format!("API_PARSE_ERROR: {} (Raw: {})", e, text),
186                            witness_receipt: "error-none".to_string(),
187                            nonce: 0,
188                            sensors: serde_json::Value::Null,
189                            reproducibility_context: serde_json::Value::Null,
190                        },
191                    }
192                } else {
193                    let text = resp.text().await.unwrap_or_else(|_| "".to_string());
194                    EvidenceCapsule {
195                        capsule_id: "error".to_string(),
196                        outcome: "HALT".to_string(),
197                        reason_code: format!("API_STATUS_ERROR: {} (Raw: {})", status, text),
198                        witness_receipt: "error-none".to_string(),
199                        nonce: 0,
200                        sensors: serde_json::Value::Null,
201                        reproducibility_context: serde_json::Value::Null,
202                    }
203                }
204            }
205            Err(e) => EvidenceCapsule {
206                capsule_id: "error".to_string(),
207                outcome: "HALT".to_string(),
208                reason_code: format!("API_CONNECTION_ERROR: {}", e),
209                witness_receipt: "error-none".to_string(),
210                nonce: 0,
211                sensors: serde_json::Value::Null,
212                reproducibility_context: serde_json::Value::Null,
213            },
214        }
215    }
216}