1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AuditEntry {
10 pub timestamp: DateTime<Utc>,
12
13 pub request_id: Uuid,
15
16 pub agent_id: String,
18
19 pub delegation_chain: String,
21
22 pub task_session_id: String,
24
25 pub tool_called: String,
27
28 pub arguments: serde_json::Value,
30
31 pub authorization_decision: String,
33
34 pub policy_matched: Option<String>,
36
37 pub anomaly_flags: Vec<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub failure_category: Option<String>,
44
45 pub latency_ms: u64,
47
48 pub upstream_status: Option<u16>,
50
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub inspection_findings: Vec<String>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub chain_sequence: Option<u64>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub chain_prev_hash: Option<String>,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub chain_record_hash: Option<String>,
69}
70
71impl AuditEntry {
72 pub fn new(request_id: Uuid) -> Self {
74 Self {
75 timestamp: Utc::now(),
76 request_id,
77 agent_id: String::new(),
78 delegation_chain: String::new(),
79 task_session_id: String::new(),
80 tool_called: String::new(),
81 arguments: serde_json::Value::Null,
82 authorization_decision: String::new(),
83 policy_matched: None,
84 anomaly_flags: Vec::new(),
85 failure_category: None,
86 latency_ms: 0,
87 upstream_status: None,
88 inspection_findings: Vec::new(),
89 chain_sequence: None,
90 chain_prev_hash: None,
91 chain_record_hash: None,
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn entry_serialization_roundtrip() {
102 let mut entry = AuditEntry::new(Uuid::new_v4());
103 entry.agent_id = "agent-1".into();
104 entry.delegation_chain = "human>agent-1".into();
105 entry.task_session_id = Uuid::new_v4().to_string();
106 entry.tool_called = "read_file".into();
107 entry.arguments = serde_json::json!({"path": "/etc/hosts"});
108 entry.authorization_decision = "allow".into();
109 entry.policy_matched = Some("policy-read-all".into());
110 entry.anomaly_flags = vec!["unusual_hour".into()];
111 entry.latency_ms = 42;
112 entry.upstream_status = Some(200);
113
114 let json = serde_json::to_string(&entry).expect("serialize");
115 let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
116
117 assert_eq!(deserialized.request_id, entry.request_id);
118 assert_eq!(deserialized.agent_id, "agent-1");
119 assert_eq!(deserialized.tool_called, "read_file");
120 assert_eq!(deserialized.latency_ms, 42);
121 assert_eq!(deserialized.upstream_status, Some(200));
122 assert_eq!(deserialized.anomaly_flags, vec!["unusual_hour"]);
123 }
124
125 #[test]
126 fn entry_defaults_are_empty() {
127 let entry = AuditEntry::new(Uuid::nil());
128 assert_eq!(entry.agent_id, "");
129 assert_eq!(entry.arguments, serde_json::Value::Null);
130 assert!(entry.anomaly_flags.is_empty());
131 assert!(entry.policy_matched.is_none());
132 assert!(entry.upstream_status.is_none());
133 }
134
135 #[test]
144 fn entry_with_newlines_in_fields() {
145 let mut entry = AuditEntry::new(Uuid::new_v4());
146 entry.agent_id = "agent\ninjected".into();
147 entry.tool_called = "tool\r\ncall".into();
148 entry.delegation_chain = "human\n>agent".into();
149 entry.task_session_id = "session\nid".into();
150
151 let json = serde_json::to_string(&entry).expect("serialize");
152
153 assert!(
156 !json.contains('\n'),
157 "JSON output must not contain raw newline (LF). Got: {}",
158 json
159 );
160 assert!(
161 !json.contains('\r'),
162 "JSON output must not contain raw carriage return (CR). Got: {}",
163 json
164 );
165
166 assert!(
168 json.contains(r#"agent\ninjected"#),
169 "agent_id newline must be escaped as \\n in JSON. Got: {}",
170 json
171 );
172 assert!(
173 json.contains(r#"tool\r\ncall"#),
174 "tool_called CRLF must be escaped as \\r\\n in JSON. Got: {}",
175 json
176 );
177
178 let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
180 assert_eq!(deserialized.agent_id, "agent\ninjected");
181 assert_eq!(deserialized.tool_called, "tool\r\ncall");
182 assert_eq!(deserialized.delegation_chain, "human\n>agent");
183 }
184
185 #[test]
194 fn entry_with_jsonl_injection_in_tool_name() {
195 let mut entry = AuditEntry::new(Uuid::new_v4());
196 entry.agent_id = "agent-1".into();
197 entry.tool_called = "read_file\n{\"injected\": true}".into();
198 entry.authorization_decision = "allow".into();
199
200 let json = serde_json::to_string(&entry).expect("serialize");
201
202 assert!(
204 !json.contains('\n'),
205 "serialized JSON must not contain raw newline (LF). Got: {}",
206 json
207 );
208 assert!(
209 !json.contains('\r'),
210 "serialized JSON must not contain raw carriage return (CR). Got: {}",
211 json
212 );
213
214 assert!(
216 json.contains(r#"read_file\n{\"injected\": true}"#),
217 "tool_called newline must be JSON-escaped as \\n. Got: {}",
218 json
219 );
220
221 let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
223 assert_eq!(
224 deserialized.tool_called, "read_file\n{\"injected\": true}",
225 "tool_called must survive serialization roundtrip with embedded newline and JSON"
226 );
227 }
228}