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    /// Monotonic sequence number for tamper detection.
56    /// A gap in sequence numbers indicates a lost or deleted entry.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub chain_sequence: Option<u64>,
59
60    /// Blake3 hash of the previous entry (hex-encoded).
61    /// Forms a hash chain for integrity verification.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub chain_prev_hash: Option<String>,
64
65    /// Blake3 hash of this entry (hex-encoded), computed over all fields
66    /// except this one.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub chain_record_hash: Option<String>,
69}
70
71impl AuditEntry {
72    /// Create a new audit entry with the given request ID and current timestamp.
73    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    // -----------------------------------------------------------------------
136    // Log injection via newlines in audit fields
137    // -----------------------------------------------------------------------
138
139    /// JSONL (JSON Lines) format requires each log entry to be a single line.
140    /// If agent_id or tool_called contain literal newlines, serde_json must
141    /// escape them as `\n` and `\r` in the output, ensuring one JSON object
142    /// per line and preventing log injection attacks.
143    #[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        // The JSON output must NOT contain raw newline characters.
154        // serde_json escapes them as \n and \r in the JSON string.
155        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        // Verify the escaped sequences are present instead.
167        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        // Verify deserialization recovers the original values.
179        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    // -----------------------------------------------------------------------
186    // JSONL injection via tool names
187    // -----------------------------------------------------------------------
188
189    /// A tool_called field containing a literal newline followed by a fake JSON
190    /// object must not break JSONL format. serde_json must escape the newline
191    /// as `\n` in the output, keeping the entire entry on one line and
192    /// preventing log injection / log splitting attacks.
193    #[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        // The serialized output must be a single line (no literal newlines).
203        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        // The escaped sequence must be present in the output.
215        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        // Roundtrip: parse it back and verify the tool name is preserved exactly.
222        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}