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
56impl AuditEntry {
57 pub fn new(request_id: Uuid) -> Self {
59 Self {
60 timestamp: Utc::now(),
61 request_id,
62 agent_id: String::new(),
63 delegation_chain: String::new(),
64 task_session_id: String::new(),
65 tool_called: String::new(),
66 arguments: serde_json::Value::Null,
67 authorization_decision: String::new(),
68 policy_matched: None,
69 anomaly_flags: Vec::new(),
70 failure_category: None,
71 latency_ms: 0,
72 upstream_status: None,
73 inspection_findings: Vec::new(),
74 }
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn entry_serialization_roundtrip() {
84 let mut entry = AuditEntry::new(Uuid::new_v4());
85 entry.agent_id = "agent-1".into();
86 entry.delegation_chain = "human>agent-1".into();
87 entry.task_session_id = Uuid::new_v4().to_string();
88 entry.tool_called = "read_file".into();
89 entry.arguments = serde_json::json!({"path": "/etc/hosts"});
90 entry.authorization_decision = "allow".into();
91 entry.policy_matched = Some("policy-read-all".into());
92 entry.anomaly_flags = vec!["unusual_hour".into()];
93 entry.latency_ms = 42;
94 entry.upstream_status = Some(200);
95
96 let json = serde_json::to_string(&entry).expect("serialize");
97 let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
98
99 assert_eq!(deserialized.request_id, entry.request_id);
100 assert_eq!(deserialized.agent_id, "agent-1");
101 assert_eq!(deserialized.tool_called, "read_file");
102 assert_eq!(deserialized.latency_ms, 42);
103 assert_eq!(deserialized.upstream_status, Some(200));
104 assert_eq!(deserialized.anomaly_flags, vec!["unusual_hour"]);
105 }
106
107 #[test]
108 fn entry_defaults_are_empty() {
109 let entry = AuditEntry::new(Uuid::nil());
110 assert_eq!(entry.agent_id, "");
111 assert_eq!(entry.arguments, serde_json::Value::Null);
112 assert!(entry.anomaly_flags.is_empty());
113 assert!(entry.policy_matched.is_none());
114 assert!(entry.upstream_status.is_none());
115 }
116
117 #[test]
126 fn entry_with_newlines_in_fields() {
127 let mut entry = AuditEntry::new(Uuid::new_v4());
128 entry.agent_id = "agent\ninjected".into();
129 entry.tool_called = "tool\r\ncall".into();
130 entry.delegation_chain = "human\n>agent".into();
131 entry.task_session_id = "session\nid".into();
132
133 let json = serde_json::to_string(&entry).expect("serialize");
134
135 assert!(
138 !json.contains('\n'),
139 "JSON output must not contain raw newline (LF). Got: {}",
140 json
141 );
142 assert!(
143 !json.contains('\r'),
144 "JSON output must not contain raw carriage return (CR). Got: {}",
145 json
146 );
147
148 assert!(
150 json.contains(r#"agent\ninjected"#),
151 "agent_id newline must be escaped as \\n in JSON. Got: {}",
152 json
153 );
154 assert!(
155 json.contains(r#"tool\r\ncall"#),
156 "tool_called CRLF must be escaped as \\r\\n in JSON. Got: {}",
157 json
158 );
159
160 let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
162 assert_eq!(deserialized.agent_id, "agent\ninjected");
163 assert_eq!(deserialized.tool_called, "tool\r\ncall");
164 assert_eq!(deserialized.delegation_chain, "human\n>agent");
165 }
166
167 #[test]
176 fn entry_with_jsonl_injection_in_tool_name() {
177 let mut entry = AuditEntry::new(Uuid::new_v4());
178 entry.agent_id = "agent-1".into();
179 entry.tool_called = "read_file\n{\"injected\": true}".into();
180 entry.authorization_decision = "allow".into();
181
182 let json = serde_json::to_string(&entry).expect("serialize");
183
184 assert!(
186 !json.contains('\n'),
187 "serialized JSON must not contain raw newline (LF). Got: {}",
188 json
189 );
190 assert!(
191 !json.contains('\r'),
192 "serialized JSON must not contain raw carriage return (CR). Got: {}",
193 json
194 );
195
196 assert!(
198 json.contains(r#"read_file\n{\"injected\": true}"#),
199 "tool_called newline must be JSON-escaped as \\n. Got: {}",
200 json
201 );
202
203 let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
205 assert_eq!(
206 deserialized.tool_called, "read_file\n{\"injected\": true}",
207 "tool_called must survive serialization roundtrip with embedded newline and JSON"
208 );
209 }
210}