use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum AgentType {
ClaudeCode,
Cursor,
Windsurf,
GithubCopilot,
CodexCli,
GeminiCli,
Cline,
#[serde(rename = "openclaw")]
OpenClaw,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum HookEventType {
PreToolUse,
UserPromptSubmit,
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Allow,
Approve,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct EventEnvelope {
pub schema_version: String,
pub id: String,
pub timestamp: String,
pub event_type: HookEventType,
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_input: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub verdict: Verdict,
pub latency_ms: u64,
pub agent_platform: AgentType,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_version: Option<String>,
pub os: String,
pub arch: String,
pub client_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct VerdictResponse {
pub schema_version: String,
pub verdict: Verdict,
pub event_id: String,
pub latency_ms: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub threat_category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details_url: Option<String>,
}
impl VerdictResponse {
pub fn allow(event_id: String, latency_ms: f64) -> Self {
Self {
schema_version: "1.0".to_string(),
verdict: Verdict::Allow,
event_id,
latency_ms,
reason: None,
severity: None,
threat_category: None,
rule_id: None,
details_url: None,
}
}
pub fn approve(event_id: String, latency_ms: f64) -> Self {
Self {
schema_version: "1.0".to_string(),
verdict: Verdict::Approve,
event_id,
latency_ms,
reason: None,
severity: None,
threat_category: None,
rule_id: None,
details_url: None,
}
}
}
pub fn new_event_id() -> String {
format!("evt_{}", uuid::Uuid::now_v7())
}
pub fn current_timestamp() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub fn os_string() -> &'static str {
std::env::consts::OS
}
pub fn arch_string() -> &'static str {
std::env::consts::ARCH
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_envelope() -> EventEnvelope {
EventEnvelope {
schema_version: "1.0".to_string(),
id: new_event_id(),
timestamp: current_timestamp(),
event_type: HookEventType::PreToolUse,
session_id: "test-session-123".to_string(),
tool_name: Some("bash".to_string()),
tool_input: Some(serde_json::json!({"command": "ls -la"})),
user_prompt: None,
reason: None,
verdict: Verdict::Allow,
latency_ms: 3,
agent_platform: AgentType::ClaudeCode,
agent_version: None,
os: os_string().to_string(),
arch: arch_string().to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
}
}
#[test]
fn test_event_envelope_serializes_all_required_fields() {
let envelope = make_test_envelope();
let json = serde_json::to_value(&envelope).expect("serialization must succeed");
assert!(
json.get("schema_version").is_some(),
"missing schema_version"
);
assert!(json.get("id").is_some(), "missing id");
assert!(json.get("timestamp").is_some(), "missing timestamp");
assert!(json.get("event_type").is_some(), "missing event_type");
assert!(json.get("session_id").is_some(), "missing session_id");
assert!(json.get("verdict").is_some(), "missing verdict");
assert!(json.get("latency_ms").is_some(), "missing latency_ms");
assert!(
json.get("agent_platform").is_some(),
"missing agent_platform"
);
assert!(json.get("os").is_some(), "missing os");
assert!(json.get("arch").is_some(), "missing arch");
assert!(
json.get("client_version").is_some(),
"missing client_version"
);
}
#[test]
fn test_agent_type_claude_code_serializes_to_kebab_case() {
let agent_type = AgentType::ClaudeCode;
let json = serde_json::to_string(&agent_type).expect("serialization must succeed");
assert_eq!(json, "\"claude-code\"");
}
#[test]
fn test_all_8_agent_types_serialize_correctly() {
let cases = [
(AgentType::ClaudeCode, "claude-code"),
(AgentType::Cursor, "cursor"),
(AgentType::Windsurf, "windsurf"),
(AgentType::GithubCopilot, "github-copilot"),
(AgentType::CodexCli, "codex-cli"),
(AgentType::GeminiCli, "gemini-cli"),
(AgentType::Cline, "cline"),
(AgentType::OpenClaw, "openclaw"),
];
for (agent, expected) in cases {
let json = serde_json::to_string(&agent).expect("serialization must succeed");
assert_eq!(
json,
format!("\"{}\"", expected),
"wrong serialization for {:?}",
agent
);
}
}
#[test]
fn test_event_envelope_id_has_evt_prefix_and_valid_uuid_v7() {
let id = new_event_id();
assert!(
id.starts_with("evt_"),
"ID must start with evt_ prefix: {}",
id
);
let uuid_part = id.strip_prefix("evt_").unwrap();
let parsed = uuid::Uuid::parse_str(uuid_part).expect("ID must contain a valid UUID");
assert_eq!(parsed.get_version_num(), 7, "UUID version must be 7");
}
#[test]
fn test_consecutive_event_ids_are_monotonically_ordered() {
let id1 = new_event_id();
let id2 = new_event_id();
assert!(
id1 <= id2,
"UUIDv7 IDs must be monotonically ordered: {} <= {}",
id1,
id2
);
}
#[test]
fn test_timestamp_ends_with_z_suffix() {
let ts = current_timestamp();
assert!(
ts.ends_with('Z'),
"Timestamp must end with 'Z' for UTC: {}",
ts
);
}
#[test]
fn test_verdict_response_serializes_optional_fields_omitted_when_none() {
let resp = VerdictResponse::allow("evt-001".to_string(), 5.0);
let json = serde_json::to_value(&resp).expect("serialization must succeed");
assert!(
json.get("reason").is_none(),
"reason should be omitted when None"
);
assert!(
json.get("severity").is_none(),
"severity should be omitted when None"
);
assert!(
json.get("threat_category").is_none(),
"threat_category should be omitted when None"
);
assert!(
json.get("rule_id").is_none(),
"rule_id should be omitted when None"
);
assert!(
json.get("details_url").is_none(),
"details_url should be omitted when None"
);
assert_eq!(json["schema_version"], "1.0");
assert_eq!(json["event_id"], "evt-001");
assert_eq!(json["latency_ms"], 5.0);
}
#[test]
fn test_verdict_allow_serializes_to_allow() {
let verdict = Verdict::Allow;
let json = serde_json::to_string(&verdict).expect("serialization must succeed");
assert_eq!(json, "\"allow\"");
}
#[test]
fn test_verdict_approve_serializes_to_approve() {
let verdict = Verdict::Approve;
let json = serde_json::to_string(&verdict).expect("serialization must succeed");
assert_eq!(json, "\"approve\"");
}
#[test]
fn test_hook_event_type_pre_tool_use_serializes_to_snake_case() {
let event_type = HookEventType::PreToolUse;
let json = serde_json::to_string(&event_type).expect("serialization must succeed");
assert_eq!(json, "\"pre_tool_use\"");
}
#[test]
fn test_event_envelope_round_trips_through_serde_json() {
let original = make_test_envelope();
let serialized = serde_json::to_string(&original).expect("serialization must succeed");
let deserialized: EventEnvelope =
serde_json::from_str(&serialized).expect("deserialization must succeed");
assert_eq!(original, deserialized);
}
#[test]
fn test_client_version_matches_cargo_pkg_version() {
let envelope = make_test_envelope();
assert_eq!(envelope.client_version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_user_prompt_serialized_when_present_omitted_when_none() {
let mut envelope = make_test_envelope();
let json = serde_json::to_value(&envelope).unwrap();
assert!(
json.get("user_prompt").is_none(),
"user_prompt should be omitted when None"
);
envelope.user_prompt = Some("tell me about Rust".to_string());
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(json["user_prompt"], "tell me about Rust");
}
#[test]
fn test_reason_serialized_when_present_omitted_when_none() {
let mut envelope = make_test_envelope();
let json = serde_json::to_value(&envelope).unwrap();
assert!(
json.get("reason").is_none(),
"reason should be omitted when None"
);
envelope.reason = Some("end_turn".to_string());
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(json["reason"], "end_turn");
}
#[test]
fn test_schema_version_serializes_as_string() {
let envelope = make_test_envelope();
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(
json["schema_version"], "1.0",
"schema_version must be string \"1.0\""
);
}
}