Skip to main content

zeph_tools/
audit.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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)]
20#[allow(clippy::struct_excessive_bools)]
21pub struct AuditEntry {
22    pub timestamp: String,
23    pub tool: String,
24    pub command: String,
25    pub result: AuditResult,
26    pub duration_ms: u64,
27    /// Fine-grained error category label from the taxonomy. `None` for successful executions.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub error_category: Option<String>,
30    /// High-level error domain for recovery dispatch. `None` for successful executions.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub error_domain: Option<String>,
33    /// Invocation phase in which the error occurred per arXiv:2601.16280 taxonomy.
34    /// `None` for successful executions.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub error_phase: Option<String>,
37    /// Provenance of the tool result. `None` for non-executor audit entries (e.g. policy checks).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub claim_source: Option<crate::executor::ClaimSource>,
40    /// MCP server ID for tool calls routed through `McpToolExecutor`. `None` for native tools.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub mcp_server_id: Option<String>,
43    /// Tool output was flagged by regex injection detection.
44    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
45    pub injection_flagged: bool,
46    /// Tool output was flagged as anomalous by the embedding guard.
47    /// Raw cosine distance is NOT stored (prevents threshold reverse-engineering).
48    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
49    pub embedding_anomalous: bool,
50    /// Tool result crossed the MCP-to-ACP trust boundary (MCP tool result served to an ACP client).
51    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
52    pub cross_boundary_mcp_to_acp: bool,
53    /// Decision recorded by the adversarial policy agent before execution.
54    ///
55    /// Values: `"allow"`, `"deny:<reason>"`, `"error:<message>"`.
56    /// `None` when adversarial policy is disabled or not applicable.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub adversarial_policy_decision: Option<String>,
59    /// Process exit code for shell tool executions. `None` for non-shell tools.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub exit_code: Option<i32>,
62    /// Whether tool output was truncated before storage. Default false.
63    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
64    pub truncated: bool,
65}
66
67#[derive(serde::Serialize)]
68#[serde(tag = "type")]
69pub enum AuditResult {
70    #[serde(rename = "success")]
71    Success,
72    #[serde(rename = "blocked")]
73    Blocked { reason: String },
74    #[serde(rename = "error")]
75    Error { message: String },
76    #[serde(rename = "timeout")]
77    Timeout,
78    #[serde(rename = "rollback")]
79    Rollback { restored: usize, deleted: usize },
80}
81
82impl AuditLogger {
83    /// Create a new `AuditLogger` from config.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if a file destination cannot be opened.
88    pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
89        let destination = if config.destination == "stdout" {
90            AuditDestination::Stdout
91        } else {
92            let file = tokio::fs::OpenOptions::new()
93                .create(true)
94                .append(true)
95                .open(Path::new(&config.destination))
96                .await?;
97            AuditDestination::File(tokio::sync::Mutex::new(file))
98        };
99
100        Ok(Self { destination })
101    }
102
103    pub async fn log(&self, entry: &AuditEntry) {
104        let json = match serde_json::to_string(entry) {
105            Ok(j) => j,
106            Err(err) => {
107                tracing::error!("audit entry serialization failed: {err}");
108                return;
109            }
110        };
111
112        match &self.destination {
113            AuditDestination::Stdout => {
114                tracing::info!(target: "audit", "{json}");
115            }
116            AuditDestination::File(file) => {
117                use tokio::io::AsyncWriteExt;
118                let mut f = file.lock().await;
119                let line = format!("{json}\n");
120                if let Err(e) = f.write_all(line.as_bytes()).await {
121                    tracing::error!("failed to write audit log: {e}");
122                } else if let Err(e) = f.flush().await {
123                    tracing::error!("failed to flush audit log: {e}");
124                }
125            }
126        }
127    }
128}
129
130#[must_use]
131pub fn chrono_now() -> String {
132    use std::time::{SystemTime, UNIX_EPOCH};
133    let secs = SystemTime::now()
134        .duration_since(UNIX_EPOCH)
135        .unwrap_or_default()
136        .as_secs();
137    format!("{secs}")
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn audit_entry_serialization() {
146        let entry = AuditEntry {
147            timestamp: "1234567890".into(),
148            tool: "shell".into(),
149            command: "echo hello".into(),
150            result: AuditResult::Success,
151            duration_ms: 42,
152            error_category: None,
153            error_domain: None,
154            error_phase: None,
155            claim_source: None,
156            mcp_server_id: None,
157            injection_flagged: false,
158            embedding_anomalous: false,
159            cross_boundary_mcp_to_acp: false,
160            adversarial_policy_decision: None,
161            exit_code: None,
162            truncated: false,
163        };
164        let json = serde_json::to_string(&entry).unwrap();
165        assert!(json.contains("\"type\":\"success\""));
166        assert!(json.contains("\"tool\":\"shell\""));
167        assert!(json.contains("\"duration_ms\":42"));
168    }
169
170    #[test]
171    fn audit_result_blocked_serialization() {
172        let entry = AuditEntry {
173            timestamp: "0".into(),
174            tool: "shell".into(),
175            command: "sudo rm".into(),
176            result: AuditResult::Blocked {
177                reason: "blocked command: sudo".into(),
178            },
179            duration_ms: 0,
180            error_category: Some("policy_blocked".to_owned()),
181            error_domain: Some("action".to_owned()),
182            error_phase: None,
183            claim_source: None,
184            mcp_server_id: None,
185            injection_flagged: false,
186            embedding_anomalous: false,
187            cross_boundary_mcp_to_acp: false,
188            adversarial_policy_decision: None,
189            exit_code: None,
190            truncated: false,
191        };
192        let json = serde_json::to_string(&entry).unwrap();
193        assert!(json.contains("\"type\":\"blocked\""));
194        assert!(json.contains("\"reason\""));
195    }
196
197    #[test]
198    fn audit_result_error_serialization() {
199        let entry = AuditEntry {
200            timestamp: "0".into(),
201            tool: "shell".into(),
202            command: "bad".into(),
203            result: AuditResult::Error {
204                message: "exec failed".into(),
205            },
206            duration_ms: 0,
207            error_category: None,
208            error_domain: None,
209            error_phase: None,
210            claim_source: None,
211            mcp_server_id: None,
212            injection_flagged: false,
213            embedding_anomalous: false,
214            cross_boundary_mcp_to_acp: false,
215            adversarial_policy_decision: None,
216            exit_code: None,
217            truncated: false,
218        };
219        let json = serde_json::to_string(&entry).unwrap();
220        assert!(json.contains("\"type\":\"error\""));
221    }
222
223    #[test]
224    fn audit_result_timeout_serialization() {
225        let entry = AuditEntry {
226            timestamp: "0".into(),
227            tool: "shell".into(),
228            command: "sleep 999".into(),
229            result: AuditResult::Timeout,
230            duration_ms: 30000,
231            error_category: Some("timeout".to_owned()),
232            error_domain: Some("system".to_owned()),
233            error_phase: None,
234            claim_source: None,
235            mcp_server_id: None,
236            injection_flagged: false,
237            embedding_anomalous: false,
238            cross_boundary_mcp_to_acp: false,
239            adversarial_policy_decision: None,
240            exit_code: None,
241            truncated: false,
242        };
243        let json = serde_json::to_string(&entry).unwrap();
244        assert!(json.contains("\"type\":\"timeout\""));
245    }
246
247    #[tokio::test]
248    async fn audit_logger_stdout() {
249        let config = AuditConfig {
250            enabled: true,
251            destination: "stdout".into(),
252        };
253        let logger = AuditLogger::from_config(&config).await.unwrap();
254        let entry = AuditEntry {
255            timestamp: "0".into(),
256            tool: "shell".into(),
257            command: "echo test".into(),
258            result: AuditResult::Success,
259            duration_ms: 1,
260            error_category: None,
261            error_domain: None,
262            error_phase: None,
263            claim_source: None,
264            mcp_server_id: None,
265            injection_flagged: false,
266            embedding_anomalous: false,
267            cross_boundary_mcp_to_acp: false,
268            adversarial_policy_decision: None,
269            exit_code: None,
270            truncated: false,
271        };
272        logger.log(&entry).await;
273    }
274
275    #[tokio::test]
276    async fn audit_logger_file() {
277        let dir = tempfile::tempdir().unwrap();
278        let path = dir.path().join("audit.log");
279        let config = AuditConfig {
280            enabled: true,
281            destination: path.display().to_string(),
282        };
283        let logger = AuditLogger::from_config(&config).await.unwrap();
284        let entry = AuditEntry {
285            timestamp: "0".into(),
286            tool: "shell".into(),
287            command: "echo test".into(),
288            result: AuditResult::Success,
289            duration_ms: 1,
290            error_category: None,
291            error_domain: None,
292            error_phase: None,
293            claim_source: None,
294            mcp_server_id: None,
295            injection_flagged: false,
296            embedding_anomalous: false,
297            cross_boundary_mcp_to_acp: false,
298            adversarial_policy_decision: None,
299            exit_code: None,
300            truncated: false,
301        };
302        logger.log(&entry).await;
303
304        let content = tokio::fs::read_to_string(&path).await.unwrap();
305        assert!(content.contains("\"tool\":\"shell\""));
306    }
307
308    #[tokio::test]
309    async fn audit_logger_file_write_error_logged() {
310        let config = AuditConfig {
311            enabled: true,
312            destination: "/nonexistent/dir/audit.log".into(),
313        };
314        let result = AuditLogger::from_config(&config).await;
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn claim_source_serde_roundtrip() {
320        use crate::executor::ClaimSource;
321        let cases = [
322            (ClaimSource::Shell, "\"shell\""),
323            (ClaimSource::FileSystem, "\"file_system\""),
324            (ClaimSource::WebScrape, "\"web_scrape\""),
325            (ClaimSource::Mcp, "\"mcp\""),
326            (ClaimSource::A2a, "\"a2a\""),
327            (ClaimSource::CodeSearch, "\"code_search\""),
328            (ClaimSource::Diagnostics, "\"diagnostics\""),
329            (ClaimSource::Memory, "\"memory\""),
330        ];
331        for (variant, expected_json) in cases {
332            let serialized = serde_json::to_string(&variant).unwrap();
333            assert_eq!(serialized, expected_json, "serialize {variant:?}");
334            let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
335            assert_eq!(deserialized, variant, "deserialize {variant:?}");
336        }
337    }
338
339    #[test]
340    fn audit_entry_claim_source_none_omitted() {
341        let entry = AuditEntry {
342            timestamp: "0".into(),
343            tool: "shell".into(),
344            command: "echo".into(),
345            result: AuditResult::Success,
346            duration_ms: 1,
347            error_category: None,
348            error_domain: None,
349            error_phase: None,
350            claim_source: None,
351            mcp_server_id: None,
352            injection_flagged: false,
353            embedding_anomalous: false,
354            cross_boundary_mcp_to_acp: false,
355            adversarial_policy_decision: None,
356            exit_code: None,
357            truncated: false,
358        };
359        let json = serde_json::to_string(&entry).unwrap();
360        assert!(
361            !json.contains("claim_source"),
362            "claim_source must be omitted when None: {json}"
363        );
364    }
365
366    #[test]
367    fn audit_entry_claim_source_some_present() {
368        use crate::executor::ClaimSource;
369        let entry = AuditEntry {
370            timestamp: "0".into(),
371            tool: "shell".into(),
372            command: "echo".into(),
373            result: AuditResult::Success,
374            duration_ms: 1,
375            error_category: None,
376            error_domain: None,
377            error_phase: None,
378            claim_source: Some(ClaimSource::Shell),
379            mcp_server_id: None,
380            injection_flagged: false,
381            embedding_anomalous: false,
382            cross_boundary_mcp_to_acp: false,
383            adversarial_policy_decision: None,
384            exit_code: None,
385            truncated: false,
386        };
387        let json = serde_json::to_string(&entry).unwrap();
388        assert!(
389            json.contains("\"claim_source\":\"shell\""),
390            "expected claim_source=shell in JSON: {json}"
391        );
392    }
393
394    #[tokio::test]
395    async fn audit_logger_multiple_entries() {
396        let dir = tempfile::tempdir().unwrap();
397        let path = dir.path().join("audit.log");
398        let config = AuditConfig {
399            enabled: true,
400            destination: path.display().to_string(),
401        };
402        let logger = AuditLogger::from_config(&config).await.unwrap();
403
404        for i in 0..5 {
405            let entry = AuditEntry {
406                timestamp: i.to_string(),
407                tool: "shell".into(),
408                command: format!("cmd{i}"),
409                result: AuditResult::Success,
410                duration_ms: i,
411                error_category: None,
412                error_domain: None,
413                error_phase: None,
414                claim_source: None,
415                mcp_server_id: None,
416                injection_flagged: false,
417                embedding_anomalous: false,
418                cross_boundary_mcp_to_acp: false,
419                adversarial_policy_decision: None,
420                exit_code: None,
421                truncated: false,
422            };
423            logger.log(&entry).await;
424        }
425
426        let content = tokio::fs::read_to_string(&path).await.unwrap();
427        assert_eq!(content.lines().count(), 5);
428    }
429
430    #[test]
431    fn audit_entry_exit_code_serialized() {
432        let entry = AuditEntry {
433            timestamp: "0".into(),
434            tool: "shell".into(),
435            command: "echo hi".into(),
436            result: AuditResult::Success,
437            duration_ms: 5,
438            error_category: None,
439            error_domain: None,
440            error_phase: None,
441            claim_source: None,
442            mcp_server_id: None,
443            injection_flagged: false,
444            embedding_anomalous: false,
445            cross_boundary_mcp_to_acp: false,
446            adversarial_policy_decision: None,
447            exit_code: Some(0),
448            truncated: false,
449        };
450        let json = serde_json::to_string(&entry).unwrap();
451        assert!(
452            json.contains("\"exit_code\":0"),
453            "exit_code must be serialized: {json}"
454        );
455    }
456
457    #[test]
458    fn audit_entry_exit_code_none_omitted() {
459        let entry = AuditEntry {
460            timestamp: "0".into(),
461            tool: "file".into(),
462            command: "read /tmp/x".into(),
463            result: AuditResult::Success,
464            duration_ms: 1,
465            error_category: None,
466            error_domain: None,
467            error_phase: None,
468            claim_source: None,
469            mcp_server_id: None,
470            injection_flagged: false,
471            embedding_anomalous: false,
472            cross_boundary_mcp_to_acp: false,
473            adversarial_policy_decision: None,
474            exit_code: None,
475            truncated: false,
476        };
477        let json = serde_json::to_string(&entry).unwrap();
478        assert!(
479            !json.contains("exit_code"),
480            "exit_code None must be omitted: {json}"
481        );
482    }
483}