Skip to main content

arbiter_audit/
entry.rs

1//! The core audit log entry structure.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// A structured audit log entry capturing a complete request lifecycle.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AuditEntry {
10    /// When this event occurred.
11    pub timestamp: DateTime<Utc>,
12
13    /// Unique identifier for this request.
14    pub request_id: Uuid,
15
16    /// The agent that made the request.
17    pub agent_id: String,
18
19    /// Serialized delegation chain (human → agent → sub-agent …).
20    pub delegation_chain: String,
21
22    /// The task session this request belongs to.
23    pub task_session_id: String,
24
25    /// The MCP tool (or HTTP path) that was called.
26    pub tool_called: String,
27
28    /// Tool arguments, with sensitive fields redacted.
29    pub arguments: serde_json::Value,
30
31    /// The authorization decision: "allow", "deny", or "escalate".
32    pub authorization_decision: String,
33
34    /// Which policy rule matched (if any).
35    pub policy_matched: Option<String>,
36
37    /// Anomaly flags raised by the behavior engine.
38    pub anomaly_flags: Vec<String>,
39
40    /// Failure category: "governance", "infrastructure", or "protocol".
41    /// Distinguishes policy denials from upstream errors in audit analysis.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub failure_category: Option<String>,
44
45    /// End-to-end latency in milliseconds.
46    pub latency_ms: u64,
47
48    /// HTTP status code from the upstream response.
49    pub upstream_status: Option<u16>,
50
51    /// Inspection findings from content inspection.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub inspection_findings: Vec<String>,
54}
55
56impl AuditEntry {
57    /// Create a new audit entry with the given request ID and current timestamp.
58    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    // -----------------------------------------------------------------------
118    // Log injection via newlines in audit fields
119    // -----------------------------------------------------------------------
120
121    /// JSONL (JSON Lines) format requires each log entry to be a single line.
122    /// If agent_id or tool_called contain literal newlines, serde_json must
123    /// escape them as `\n` and `\r` in the output, ensuring one JSON object
124    /// per line and preventing log injection attacks.
125    #[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        // The JSON output must NOT contain raw newline characters.
136        // serde_json escapes them as \n and \r in the JSON string.
137        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        // Verify the escaped sequences are present instead.
149        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        // Verify deserialization recovers the original values.
161        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    // -----------------------------------------------------------------------
168    // JSONL injection via tool names
169    // -----------------------------------------------------------------------
170
171    /// A tool_called field containing a literal newline followed by a fake JSON
172    /// object must not break JSONL format. serde_json must escape the newline
173    /// as `\n` in the output, keeping the entire entry on one line and
174    /// preventing log injection / log splitting attacks.
175    #[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        // The serialized output must be a single line (no literal newlines).
185        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        // The escaped sequence must be present in the output.
197        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        // Roundtrip: parse it back and verify the tool name is preserved exactly.
204        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}