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)]
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    /// Fine-grained error category label from the taxonomy. `None` for successful executions.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub error_category: Option<String>,
29    /// High-level error domain for recovery dispatch. `None` for successful executions.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub error_domain: Option<String>,
32    /// Invocation phase in which the error occurred per arXiv:2601.16280 taxonomy.
33    /// `None` for successful executions.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub error_phase: Option<String>,
36    /// Provenance of the tool result. `None` for non-executor audit entries (e.g. policy checks).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub claim_source: Option<crate::executor::ClaimSource>,
39    /// MCP server ID for tool calls routed through `McpToolExecutor`. `None` for native tools.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub mcp_server_id: Option<String>,
42    /// Tool output was flagged by regex injection detection.
43    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44    pub injection_flagged: bool,
45    /// Tool output was flagged as anomalous by the embedding guard.
46    /// Raw cosine distance is NOT stored (prevents threshold reverse-engineering).
47    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
48    pub embedding_anomalous: bool,
49    /// Tool result crossed the MCP-to-ACP trust boundary (MCP tool result served to an ACP client).
50    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51    pub cross_boundary_mcp_to_acp: bool,
52    /// Decision recorded by the adversarial policy agent before execution.
53    ///
54    /// Values: `"allow"`, `"deny:<reason>"`, `"error:<message>"`.
55    /// `None` when adversarial policy is disabled or not applicable.
56    #[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    /// Create a new `AuditLogger` from config.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if a file destination cannot be opened.
81    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}