use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub request_id: Uuid,
pub agent_id: String,
pub delegation_chain: String,
pub task_session_id: String,
pub tool_called: String,
pub arguments: serde_json::Value,
pub authorization_decision: String,
pub policy_matched: Option<String>,
pub anomaly_flags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_category: Option<String>,
pub latency_ms: u64,
pub upstream_status: Option<u16>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inspection_findings: Vec<String>,
}
impl AuditEntry {
pub fn new(request_id: Uuid) -> Self {
Self {
timestamp: Utc::now(),
request_id,
agent_id: String::new(),
delegation_chain: String::new(),
task_session_id: String::new(),
tool_called: String::new(),
arguments: serde_json::Value::Null,
authorization_decision: String::new(),
policy_matched: None,
anomaly_flags: Vec::new(),
failure_category: None,
latency_ms: 0,
upstream_status: None,
inspection_findings: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_serialization_roundtrip() {
let mut entry = AuditEntry::new(Uuid::new_v4());
entry.agent_id = "agent-1".into();
entry.delegation_chain = "human>agent-1".into();
entry.task_session_id = Uuid::new_v4().to_string();
entry.tool_called = "read_file".into();
entry.arguments = serde_json::json!({"path": "/etc/hosts"});
entry.authorization_decision = "allow".into();
entry.policy_matched = Some("policy-read-all".into());
entry.anomaly_flags = vec!["unusual_hour".into()];
entry.latency_ms = 42;
entry.upstream_status = Some(200);
let json = serde_json::to_string(&entry).expect("serialize");
let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.request_id, entry.request_id);
assert_eq!(deserialized.agent_id, "agent-1");
assert_eq!(deserialized.tool_called, "read_file");
assert_eq!(deserialized.latency_ms, 42);
assert_eq!(deserialized.upstream_status, Some(200));
assert_eq!(deserialized.anomaly_flags, vec!["unusual_hour"]);
}
#[test]
fn entry_defaults_are_empty() {
let entry = AuditEntry::new(Uuid::nil());
assert_eq!(entry.agent_id, "");
assert_eq!(entry.arguments, serde_json::Value::Null);
assert!(entry.anomaly_flags.is_empty());
assert!(entry.policy_matched.is_none());
assert!(entry.upstream_status.is_none());
}
#[test]
fn entry_with_newlines_in_fields() {
let mut entry = AuditEntry::new(Uuid::new_v4());
entry.agent_id = "agent\ninjected".into();
entry.tool_called = "tool\r\ncall".into();
entry.delegation_chain = "human\n>agent".into();
entry.task_session_id = "session\nid".into();
let json = serde_json::to_string(&entry).expect("serialize");
assert!(
!json.contains('\n'),
"JSON output must not contain raw newline (LF). Got: {}",
json
);
assert!(
!json.contains('\r'),
"JSON output must not contain raw carriage return (CR). Got: {}",
json
);
assert!(
json.contains(r#"agent\ninjected"#),
"agent_id newline must be escaped as \\n in JSON. Got: {}",
json
);
assert!(
json.contains(r#"tool\r\ncall"#),
"tool_called CRLF must be escaped as \\r\\n in JSON. Got: {}",
json
);
let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.agent_id, "agent\ninjected");
assert_eq!(deserialized.tool_called, "tool\r\ncall");
assert_eq!(deserialized.delegation_chain, "human\n>agent");
}
#[test]
fn entry_with_jsonl_injection_in_tool_name() {
let mut entry = AuditEntry::new(Uuid::new_v4());
entry.agent_id = "agent-1".into();
entry.tool_called = "read_file\n{\"injected\": true}".into();
entry.authorization_decision = "allow".into();
let json = serde_json::to_string(&entry).expect("serialize");
assert!(
!json.contains('\n'),
"serialized JSON must not contain raw newline (LF). Got: {}",
json
);
assert!(
!json.contains('\r'),
"serialized JSON must not contain raw carriage return (CR). Got: {}",
json
);
assert!(
json.contains(r#"read_file\n{\"injected\": true}"#),
"tool_called newline must be JSON-escaped as \\n. Got: {}",
json
);
let deserialized: AuditEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
deserialized.tool_called, "read_file\n{\"injected\": true}",
"tool_called must survive serialization roundtrip with embedded newline and JSON"
);
}
}