openlatch_client/core/envelope/
mod.rs1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub enum AgentType {
18 ClaudeCode,
19 Cursor,
20 Windsurf,
21 GithubCopilot,
22 CodexCli,
23 GeminiCli,
24 Cline,
25 #[serde(rename = "openclaw")]
28 OpenClaw,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
35#[serde(rename_all = "snake_case")]
36pub enum HookEventType {
37 PreToolUse,
38 UserPromptSubmit,
39 Stop,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "lowercase")]
50pub enum Verdict {
51 Allow,
52 Approve,
53 Deny,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88#[serde(rename_all = "snake_case")]
89pub struct EventEnvelope {
90 pub schema_version: String,
91 pub id: String,
92 pub timestamp: String,
93 pub event_type: HookEventType,
94 pub session_id: String,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub tool_name: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub tool_input: Option<serde_json::Value>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub user_prompt: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub reason: Option<String>,
103 pub verdict: Verdict,
104 pub latency_ms: u64,
105 pub agent_platform: AgentType,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub agent_version: Option<String>,
108 pub os: String,
109 pub arch: String,
110 pub client_version: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(rename_all = "snake_case")]
119pub struct VerdictResponse {
120 pub schema_version: String,
121 pub verdict: Verdict,
122 pub event_id: String,
123 pub latency_ms: f64,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub reason: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub severity: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub threat_category: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub rule_id: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub details_url: Option<String>,
134}
135
136impl VerdictResponse {
137 pub fn allow(event_id: String, latency_ms: f64) -> Self {
139 Self {
140 schema_version: "1.0".to_string(),
141 verdict: Verdict::Allow,
142 event_id,
143 latency_ms,
144 reason: None,
145 severity: None,
146 threat_category: None,
147 rule_id: None,
148 details_url: None,
149 }
150 }
151
152 pub fn approve(event_id: String, latency_ms: f64) -> Self {
154 Self {
155 schema_version: "1.0".to_string(),
156 verdict: Verdict::Approve,
157 event_id,
158 latency_ms,
159 reason: None,
160 severity: None,
161 threat_category: None,
162 rule_id: None,
163 details_url: None,
164 }
165 }
166}
167
168pub fn new_event_id() -> String {
173 format!("evt_{}", uuid::Uuid::now_v7())
174}
175
176pub fn current_timestamp() -> String {
182 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
183}
184
185pub fn os_string() -> &'static str {
189 std::env::consts::OS
190}
191
192pub fn arch_string() -> &'static str {
196 std::env::consts::ARCH
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn make_test_envelope() -> EventEnvelope {
204 EventEnvelope {
205 schema_version: "1.0".to_string(),
206 id: new_event_id(),
207 timestamp: current_timestamp(),
208 event_type: HookEventType::PreToolUse,
209 session_id: "test-session-123".to_string(),
210 tool_name: Some("bash".to_string()),
211 tool_input: Some(serde_json::json!({"command": "ls -la"})),
212 user_prompt: None,
213 reason: None,
214 verdict: Verdict::Allow,
215 latency_ms: 3,
216 agent_platform: AgentType::ClaudeCode,
217 agent_version: None,
218 os: os_string().to_string(),
219 arch: arch_string().to_string(),
220 client_version: env!("CARGO_PKG_VERSION").to_string(),
221 }
222 }
223
224 #[test]
225 fn test_event_envelope_serializes_all_required_fields() {
226 let envelope = make_test_envelope();
227 let json = serde_json::to_value(&envelope).expect("serialization must succeed");
228
229 assert!(
231 json.get("schema_version").is_some(),
232 "missing schema_version"
233 );
234 assert!(json.get("id").is_some(), "missing id");
235 assert!(json.get("timestamp").is_some(), "missing timestamp");
236 assert!(json.get("event_type").is_some(), "missing event_type");
237 assert!(json.get("session_id").is_some(), "missing session_id");
238 assert!(json.get("verdict").is_some(), "missing verdict");
239 assert!(json.get("latency_ms").is_some(), "missing latency_ms");
240 assert!(
241 json.get("agent_platform").is_some(),
242 "missing agent_platform"
243 );
244 assert!(json.get("os").is_some(), "missing os");
245 assert!(json.get("arch").is_some(), "missing arch");
246 assert!(
247 json.get("client_version").is_some(),
248 "missing client_version"
249 );
250 }
251
252 #[test]
253 fn test_agent_type_claude_code_serializes_to_kebab_case() {
254 let agent_type = AgentType::ClaudeCode;
255 let json = serde_json::to_string(&agent_type).expect("serialization must succeed");
256 assert_eq!(json, "\"claude-code\"");
257 }
258
259 #[test]
260 fn test_all_8_agent_types_serialize_correctly() {
261 let cases = [
262 (AgentType::ClaudeCode, "claude-code"),
263 (AgentType::Cursor, "cursor"),
264 (AgentType::Windsurf, "windsurf"),
265 (AgentType::GithubCopilot, "github-copilot"),
266 (AgentType::CodexCli, "codex-cli"),
267 (AgentType::GeminiCli, "gemini-cli"),
268 (AgentType::Cline, "cline"),
269 (AgentType::OpenClaw, "openclaw"),
270 ];
271
272 for (agent, expected) in cases {
273 let json = serde_json::to_string(&agent).expect("serialization must succeed");
274 assert_eq!(
275 json,
276 format!("\"{}\"", expected),
277 "wrong serialization for {:?}",
278 agent
279 );
280 }
281 }
282
283 #[test]
284 fn test_event_envelope_id_has_evt_prefix_and_valid_uuid_v7() {
285 let id = new_event_id();
286 assert!(
287 id.starts_with("evt_"),
288 "ID must start with evt_ prefix: {}",
289 id
290 );
291 let uuid_part = id.strip_prefix("evt_").unwrap();
292 let parsed = uuid::Uuid::parse_str(uuid_part).expect("ID must contain a valid UUID");
293 assert_eq!(parsed.get_version_num(), 7, "UUID version must be 7");
295 }
296
297 #[test]
298 fn test_consecutive_event_ids_are_monotonically_ordered() {
299 let id1 = new_event_id();
300 let id2 = new_event_id();
302 assert!(
303 id1 <= id2,
304 "UUIDv7 IDs must be monotonically ordered: {} <= {}",
305 id1,
306 id2
307 );
308 }
309
310 #[test]
311 fn test_timestamp_ends_with_z_suffix() {
312 let ts = current_timestamp();
313 assert!(
314 ts.ends_with('Z'),
315 "Timestamp must end with 'Z' for UTC: {}",
316 ts
317 );
318 }
319
320 #[test]
321 fn test_verdict_response_serializes_optional_fields_omitted_when_none() {
322 let resp = VerdictResponse::allow("evt-001".to_string(), 5.0);
323 let json = serde_json::to_value(&resp).expect("serialization must succeed");
324
325 assert!(
327 json.get("reason").is_none(),
328 "reason should be omitted when None"
329 );
330 assert!(
331 json.get("severity").is_none(),
332 "severity should be omitted when None"
333 );
334 assert!(
335 json.get("threat_category").is_none(),
336 "threat_category should be omitted when None"
337 );
338 assert!(
339 json.get("rule_id").is_none(),
340 "rule_id should be omitted when None"
341 );
342 assert!(
343 json.get("details_url").is_none(),
344 "details_url should be omitted when None"
345 );
346
347 assert_eq!(json["schema_version"], "1.0");
349 assert_eq!(json["event_id"], "evt-001");
350 assert_eq!(json["latency_ms"], 5.0);
351 }
352
353 #[test]
354 fn test_verdict_allow_serializes_to_allow() {
355 let verdict = Verdict::Allow;
356 let json = serde_json::to_string(&verdict).expect("serialization must succeed");
357 assert_eq!(json, "\"allow\"");
358 }
359
360 #[test]
361 fn test_verdict_approve_serializes_to_approve() {
362 let verdict = Verdict::Approve;
363 let json = serde_json::to_string(&verdict).expect("serialization must succeed");
364 assert_eq!(json, "\"approve\"");
365 }
366
367 #[test]
368 fn test_hook_event_type_pre_tool_use_serializes_to_snake_case() {
369 let event_type = HookEventType::PreToolUse;
370 let json = serde_json::to_string(&event_type).expect("serialization must succeed");
371 assert_eq!(json, "\"pre_tool_use\"");
372 }
373
374 #[test]
375 fn test_event_envelope_round_trips_through_serde_json() {
376 let original = make_test_envelope();
377 let serialized = serde_json::to_string(&original).expect("serialization must succeed");
378 let deserialized: EventEnvelope =
379 serde_json::from_str(&serialized).expect("deserialization must succeed");
380 assert_eq!(original, deserialized);
381 }
382
383 #[test]
384 fn test_client_version_matches_cargo_pkg_version() {
385 let envelope = make_test_envelope();
386 assert_eq!(envelope.client_version, env!("CARGO_PKG_VERSION"));
387 }
388
389 #[test]
390 fn test_user_prompt_serialized_when_present_omitted_when_none() {
391 let mut envelope = make_test_envelope();
392
393 let json = serde_json::to_value(&envelope).unwrap();
395 assert!(
396 json.get("user_prompt").is_none(),
397 "user_prompt should be omitted when None"
398 );
399
400 envelope.user_prompt = Some("tell me about Rust".to_string());
402 let json = serde_json::to_value(&envelope).unwrap();
403 assert_eq!(json["user_prompt"], "tell me about Rust");
404 }
405
406 #[test]
407 fn test_reason_serialized_when_present_omitted_when_none() {
408 let mut envelope = make_test_envelope();
409
410 let json = serde_json::to_value(&envelope).unwrap();
412 assert!(
413 json.get("reason").is_none(),
414 "reason should be omitted when None"
415 );
416
417 envelope.reason = Some("end_turn".to_string());
419 let json = serde_json::to_value(&envelope).unwrap();
420 assert_eq!(json["reason"], "end_turn");
421 }
422
423 #[test]
424 fn test_schema_version_serializes_as_string() {
425 let envelope = make_test_envelope();
426 let json = serde_json::to_value(&envelope).unwrap();
427 assert_eq!(
428 json["schema_version"], "1.0",
429 "schema_version must be string \"1.0\""
430 );
431 }
432}