1use std::path::Path;
5
6use crate::config::AuditConfig;
7
8#[derive(Debug)]
9pub struct AuditLogger {
10 destination: AuditDestination,
11}
12
13#[derive(Debug)]
14enum AuditDestination {
15 Stdout,
16 File(tokio::sync::Mutex<tokio::fs::File>),
17}
18
19#[derive(serde::Serialize)]
20pub struct AuditEntry {
21 pub timestamp: String,
22 pub tool: String,
23 pub command: String,
24 pub result: AuditResult,
25 pub duration_ms: u64,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub error_category: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub error_domain: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
35 pub error_phase: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub claim_source: Option<crate::executor::ClaimSource>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub mcp_server_id: Option<String>,
42 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44 pub injection_flagged: bool,
45 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
48 pub embedding_anomalous: bool,
49 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51 pub cross_boundary_mcp_to_acp: bool,
52 #[serde(skip_serializing_if = "Option::is_none")]
57 pub adversarial_policy_decision: Option<String>,
58}
59
60#[derive(serde::Serialize)]
61#[serde(tag = "type")]
62pub enum AuditResult {
63 #[serde(rename = "success")]
64 Success,
65 #[serde(rename = "blocked")]
66 Blocked { reason: String },
67 #[serde(rename = "error")]
68 Error { message: String },
69 #[serde(rename = "timeout")]
70 Timeout,
71 #[serde(rename = "rollback")]
72 Rollback { restored: usize, deleted: usize },
73}
74
75impl AuditLogger {
76 pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
82 let destination = if config.destination == "stdout" {
83 AuditDestination::Stdout
84 } else {
85 let file = tokio::fs::OpenOptions::new()
86 .create(true)
87 .append(true)
88 .open(Path::new(&config.destination))
89 .await?;
90 AuditDestination::File(tokio::sync::Mutex::new(file))
91 };
92
93 Ok(Self { destination })
94 }
95
96 pub async fn log(&self, entry: &AuditEntry) {
97 let json = match serde_json::to_string(entry) {
98 Ok(j) => j,
99 Err(err) => {
100 tracing::error!("audit entry serialization failed: {err}");
101 return;
102 }
103 };
104
105 match &self.destination {
106 AuditDestination::Stdout => {
107 tracing::info!(target: "audit", "{json}");
108 }
109 AuditDestination::File(file) => {
110 use tokio::io::AsyncWriteExt;
111 let mut f = file.lock().await;
112 let line = format!("{json}\n");
113 if let Err(e) = f.write_all(line.as_bytes()).await {
114 tracing::error!("failed to write audit log: {e}");
115 } else if let Err(e) = f.flush().await {
116 tracing::error!("failed to flush audit log: {e}");
117 }
118 }
119 }
120 }
121}
122
123#[must_use]
124pub fn chrono_now() -> String {
125 use std::time::{SystemTime, UNIX_EPOCH};
126 let secs = SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .unwrap_or_default()
129 .as_secs();
130 format!("{secs}")
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn audit_entry_serialization() {
139 let entry = AuditEntry {
140 timestamp: "1234567890".into(),
141 tool: "shell".into(),
142 command: "echo hello".into(),
143 result: AuditResult::Success,
144 duration_ms: 42,
145 error_category: None,
146 error_domain: None,
147 error_phase: None,
148 claim_source: None,
149 mcp_server_id: None,
150 injection_flagged: false,
151 embedding_anomalous: false,
152 cross_boundary_mcp_to_acp: false,
153 adversarial_policy_decision: None,
154 };
155 let json = serde_json::to_string(&entry).unwrap();
156 assert!(json.contains("\"type\":\"success\""));
157 assert!(json.contains("\"tool\":\"shell\""));
158 assert!(json.contains("\"duration_ms\":42"));
159 }
160
161 #[test]
162 fn audit_result_blocked_serialization() {
163 let entry = AuditEntry {
164 timestamp: "0".into(),
165 tool: "shell".into(),
166 command: "sudo rm".into(),
167 result: AuditResult::Blocked {
168 reason: "blocked command: sudo".into(),
169 },
170 duration_ms: 0,
171 error_category: Some("policy_blocked".to_owned()),
172 error_domain: Some("action".to_owned()),
173 error_phase: None,
174 claim_source: None,
175 mcp_server_id: None,
176 injection_flagged: false,
177 embedding_anomalous: false,
178 cross_boundary_mcp_to_acp: false,
179 adversarial_policy_decision: None,
180 };
181 let json = serde_json::to_string(&entry).unwrap();
182 assert!(json.contains("\"type\":\"blocked\""));
183 assert!(json.contains("\"reason\""));
184 }
185
186 #[test]
187 fn audit_result_error_serialization() {
188 let entry = AuditEntry {
189 timestamp: "0".into(),
190 tool: "shell".into(),
191 command: "bad".into(),
192 result: AuditResult::Error {
193 message: "exec failed".into(),
194 },
195 duration_ms: 0,
196 error_category: None,
197 error_domain: None,
198 error_phase: None,
199 claim_source: None,
200 mcp_server_id: None,
201 injection_flagged: false,
202 embedding_anomalous: false,
203 cross_boundary_mcp_to_acp: false,
204 adversarial_policy_decision: None,
205 };
206 let json = serde_json::to_string(&entry).unwrap();
207 assert!(json.contains("\"type\":\"error\""));
208 }
209
210 #[test]
211 fn audit_result_timeout_serialization() {
212 let entry = AuditEntry {
213 timestamp: "0".into(),
214 tool: "shell".into(),
215 command: "sleep 999".into(),
216 result: AuditResult::Timeout,
217 duration_ms: 30000,
218 error_category: Some("timeout".to_owned()),
219 error_domain: Some("system".to_owned()),
220 error_phase: None,
221 claim_source: None,
222 mcp_server_id: None,
223 injection_flagged: false,
224 embedding_anomalous: false,
225 cross_boundary_mcp_to_acp: false,
226 adversarial_policy_decision: None,
227 };
228 let json = serde_json::to_string(&entry).unwrap();
229 assert!(json.contains("\"type\":\"timeout\""));
230 }
231
232 #[tokio::test]
233 async fn audit_logger_stdout() {
234 let config = AuditConfig {
235 enabled: true,
236 destination: "stdout".into(),
237 };
238 let logger = AuditLogger::from_config(&config).await.unwrap();
239 let entry = AuditEntry {
240 timestamp: "0".into(),
241 tool: "shell".into(),
242 command: "echo test".into(),
243 result: AuditResult::Success,
244 duration_ms: 1,
245 error_category: None,
246 error_domain: None,
247 error_phase: None,
248 claim_source: None,
249 mcp_server_id: None,
250 injection_flagged: false,
251 embedding_anomalous: false,
252 cross_boundary_mcp_to_acp: false,
253 adversarial_policy_decision: None,
254 };
255 logger.log(&entry).await;
256 }
257
258 #[tokio::test]
259 async fn audit_logger_file() {
260 let dir = tempfile::tempdir().unwrap();
261 let path = dir.path().join("audit.log");
262 let config = AuditConfig {
263 enabled: true,
264 destination: path.display().to_string(),
265 };
266 let logger = AuditLogger::from_config(&config).await.unwrap();
267 let entry = AuditEntry {
268 timestamp: "0".into(),
269 tool: "shell".into(),
270 command: "echo test".into(),
271 result: AuditResult::Success,
272 duration_ms: 1,
273 error_category: None,
274 error_domain: None,
275 error_phase: None,
276 claim_source: None,
277 mcp_server_id: None,
278 injection_flagged: false,
279 embedding_anomalous: false,
280 cross_boundary_mcp_to_acp: false,
281 adversarial_policy_decision: None,
282 };
283 logger.log(&entry).await;
284
285 let content = tokio::fs::read_to_string(&path).await.unwrap();
286 assert!(content.contains("\"tool\":\"shell\""));
287 }
288
289 #[tokio::test]
290 async fn audit_logger_file_write_error_logged() {
291 let config = AuditConfig {
292 enabled: true,
293 destination: "/nonexistent/dir/audit.log".into(),
294 };
295 let result = AuditLogger::from_config(&config).await;
296 assert!(result.is_err());
297 }
298
299 #[test]
300 fn claim_source_serde_roundtrip() {
301 use crate::executor::ClaimSource;
302 let cases = [
303 (ClaimSource::Shell, "\"shell\""),
304 (ClaimSource::FileSystem, "\"file_system\""),
305 (ClaimSource::WebScrape, "\"web_scrape\""),
306 (ClaimSource::Mcp, "\"mcp\""),
307 (ClaimSource::A2a, "\"a2a\""),
308 (ClaimSource::CodeSearch, "\"code_search\""),
309 (ClaimSource::Diagnostics, "\"diagnostics\""),
310 (ClaimSource::Memory, "\"memory\""),
311 ];
312 for (variant, expected_json) in cases {
313 let serialized = serde_json::to_string(&variant).unwrap();
314 assert_eq!(serialized, expected_json, "serialize {variant:?}");
315 let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
316 assert_eq!(deserialized, variant, "deserialize {variant:?}");
317 }
318 }
319
320 #[test]
321 fn audit_entry_claim_source_none_omitted() {
322 let entry = AuditEntry {
323 timestamp: "0".into(),
324 tool: "shell".into(),
325 command: "echo".into(),
326 result: AuditResult::Success,
327 duration_ms: 1,
328 error_category: None,
329 error_domain: None,
330 error_phase: None,
331 claim_source: None,
332 mcp_server_id: None,
333 injection_flagged: false,
334 embedding_anomalous: false,
335 cross_boundary_mcp_to_acp: false,
336 adversarial_policy_decision: None,
337 };
338 let json = serde_json::to_string(&entry).unwrap();
339 assert!(
340 !json.contains("claim_source"),
341 "claim_source must be omitted when None: {json}"
342 );
343 }
344
345 #[test]
346 fn audit_entry_claim_source_some_present() {
347 use crate::executor::ClaimSource;
348 let entry = AuditEntry {
349 timestamp: "0".into(),
350 tool: "shell".into(),
351 command: "echo".into(),
352 result: AuditResult::Success,
353 duration_ms: 1,
354 error_category: None,
355 error_domain: None,
356 error_phase: None,
357 claim_source: Some(ClaimSource::Shell),
358 mcp_server_id: None,
359 injection_flagged: false,
360 embedding_anomalous: false,
361 cross_boundary_mcp_to_acp: false,
362 adversarial_policy_decision: None,
363 };
364 let json = serde_json::to_string(&entry).unwrap();
365 assert!(
366 json.contains("\"claim_source\":\"shell\""),
367 "expected claim_source=shell in JSON: {json}"
368 );
369 }
370
371 #[tokio::test]
372 async fn audit_logger_multiple_entries() {
373 let dir = tempfile::tempdir().unwrap();
374 let path = dir.path().join("audit.log");
375 let config = AuditConfig {
376 enabled: true,
377 destination: path.display().to_string(),
378 };
379 let logger = AuditLogger::from_config(&config).await.unwrap();
380
381 for i in 0..5 {
382 let entry = AuditEntry {
383 timestamp: i.to_string(),
384 tool: "shell".into(),
385 command: format!("cmd{i}"),
386 result: AuditResult::Success,
387 duration_ms: i,
388 error_category: None,
389 error_domain: None,
390 error_phase: None,
391 claim_source: None,
392 mcp_server_id: None,
393 injection_flagged: false,
394 embedding_anomalous: false,
395 cross_boundary_mcp_to_acp: false,
396 adversarial_policy_decision: None,
397 };
398 logger.log(&entry).await;
399 }
400
401 let content = tokio::fs::read_to_string(&path).await.unwrap();
402 assert_eq!(content.lines().count(), 5);
403 }
404}