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    /// Caller identity that initiated this tool call. `None` for system calls.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub caller_id: Option<String>,
68    /// Policy rule trace that matched this tool call. Populated from `PolicyDecision::trace`.
69    /// `None` when policy is disabled or this entry is not from a policy check.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub policy_match: Option<String>,
72}
73
74#[derive(serde::Serialize)]
75#[serde(tag = "type")]
76pub enum AuditResult {
77    #[serde(rename = "success")]
78    Success,
79    #[serde(rename = "blocked")]
80    Blocked { reason: String },
81    #[serde(rename = "error")]
82    Error { message: String },
83    #[serde(rename = "timeout")]
84    Timeout,
85    #[serde(rename = "rollback")]
86    Rollback { restored: usize, deleted: usize },
87}
88
89impl AuditLogger {
90    /// Create a new `AuditLogger` from config.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if a file destination cannot be opened.
95    pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
96        let destination = if config.destination == "stdout" {
97            AuditDestination::Stdout
98        } else {
99            let file = tokio::fs::OpenOptions::new()
100                .create(true)
101                .append(true)
102                .open(Path::new(&config.destination))
103                .await?;
104            AuditDestination::File(tokio::sync::Mutex::new(file))
105        };
106
107        Ok(Self { destination })
108    }
109
110    pub async fn log(&self, entry: &AuditEntry) {
111        let json = match serde_json::to_string(entry) {
112            Ok(j) => j,
113            Err(err) => {
114                tracing::error!("audit entry serialization failed: {err}");
115                return;
116            }
117        };
118
119        match &self.destination {
120            AuditDestination::Stdout => {
121                tracing::info!(target: "audit", "{json}");
122            }
123            AuditDestination::File(file) => {
124                use tokio::io::AsyncWriteExt;
125                let mut f = file.lock().await;
126                let line = format!("{json}\n");
127                if let Err(e) = f.write_all(line.as_bytes()).await {
128                    tracing::error!("failed to write audit log: {e}");
129                } else if let Err(e) = f.flush().await {
130                    tracing::error!("failed to flush audit log: {e}");
131                }
132            }
133        }
134    }
135}
136
137/// Log a per-tool risk summary at startup when `audit.tool_risk_summary = true`.
138///
139/// Each entry records tool name, privilege level (static mapping by tool id), and the
140/// expected input sanitization method. This is a design-time inventory label —
141/// NOT a runtime guarantee that sanitization is functioning correctly.
142pub fn log_tool_risk_summary(tool_ids: &[&str]) {
143    // Static privilege mapping: tool id prefix → (privilege level, expected sanitization).
144    // "high" = can execute arbitrary OS commands; "medium" = network/filesystem access;
145    // "low" = schema-validated parameters only.
146    fn classify(id: &str) -> (&'static str, &'static str) {
147        if id.starts_with("shell") || id == "bash" || id == "exec" {
148            ("high", "env_blocklist + command_blocklist")
149        } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
150            ("medium", "validate_url + SSRF + domain_policy")
151        } else if id.starts_with("file_write")
152            || id.starts_with("file_read")
153            || id.starts_with("file")
154        {
155            ("medium", "path_sandbox")
156        } else {
157            ("low", "schema_only")
158        }
159    }
160
161    for &id in tool_ids {
162        let (privilege, sanitization) = classify(id);
163        tracing::info!(
164            tool = id,
165            privilege_level = privilege,
166            expected_sanitization = sanitization,
167            "tool risk summary"
168        );
169    }
170}
171
172#[must_use]
173pub fn chrono_now() -> String {
174    use std::time::{SystemTime, UNIX_EPOCH};
175    let secs = SystemTime::now()
176        .duration_since(UNIX_EPOCH)
177        .unwrap_or_default()
178        .as_secs();
179    format!("{secs}")
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn audit_entry_serialization() {
188        let entry = AuditEntry {
189            timestamp: "1234567890".into(),
190            tool: "shell".into(),
191            command: "echo hello".into(),
192            result: AuditResult::Success,
193            duration_ms: 42,
194            error_category: None,
195            error_domain: None,
196            error_phase: None,
197            claim_source: None,
198            mcp_server_id: None,
199            injection_flagged: false,
200            embedding_anomalous: false,
201            cross_boundary_mcp_to_acp: false,
202            adversarial_policy_decision: None,
203            exit_code: None,
204            truncated: false,
205            policy_match: None,
206            caller_id: None,
207        };
208        let json = serde_json::to_string(&entry).unwrap();
209        assert!(json.contains("\"type\":\"success\""));
210        assert!(json.contains("\"tool\":\"shell\""));
211        assert!(json.contains("\"duration_ms\":42"));
212    }
213
214    #[test]
215    fn audit_result_blocked_serialization() {
216        let entry = AuditEntry {
217            timestamp: "0".into(),
218            tool: "shell".into(),
219            command: "sudo rm".into(),
220            result: AuditResult::Blocked {
221                reason: "blocked command: sudo".into(),
222            },
223            duration_ms: 0,
224            error_category: Some("policy_blocked".to_owned()),
225            error_domain: Some("action".to_owned()),
226            error_phase: None,
227            claim_source: None,
228            mcp_server_id: None,
229            injection_flagged: false,
230            embedding_anomalous: false,
231            cross_boundary_mcp_to_acp: false,
232            adversarial_policy_decision: None,
233            exit_code: None,
234            truncated: false,
235            policy_match: None,
236            caller_id: None,
237        };
238        let json = serde_json::to_string(&entry).unwrap();
239        assert!(json.contains("\"type\":\"blocked\""));
240        assert!(json.contains("\"reason\""));
241    }
242
243    #[test]
244    fn audit_result_error_serialization() {
245        let entry = AuditEntry {
246            timestamp: "0".into(),
247            tool: "shell".into(),
248            command: "bad".into(),
249            result: AuditResult::Error {
250                message: "exec failed".into(),
251            },
252            duration_ms: 0,
253            error_category: None,
254            error_domain: None,
255            error_phase: None,
256            claim_source: None,
257            mcp_server_id: None,
258            injection_flagged: false,
259            embedding_anomalous: false,
260            cross_boundary_mcp_to_acp: false,
261            adversarial_policy_decision: None,
262            exit_code: None,
263            truncated: false,
264            policy_match: None,
265            caller_id: None,
266        };
267        let json = serde_json::to_string(&entry).unwrap();
268        assert!(json.contains("\"type\":\"error\""));
269    }
270
271    #[test]
272    fn audit_result_timeout_serialization() {
273        let entry = AuditEntry {
274            timestamp: "0".into(),
275            tool: "shell".into(),
276            command: "sleep 999".into(),
277            result: AuditResult::Timeout,
278            duration_ms: 30000,
279            error_category: Some("timeout".to_owned()),
280            error_domain: Some("system".to_owned()),
281            error_phase: None,
282            claim_source: None,
283            mcp_server_id: None,
284            injection_flagged: false,
285            embedding_anomalous: false,
286            cross_boundary_mcp_to_acp: false,
287            adversarial_policy_decision: None,
288            exit_code: None,
289            truncated: false,
290            policy_match: None,
291            caller_id: None,
292        };
293        let json = serde_json::to_string(&entry).unwrap();
294        assert!(json.contains("\"type\":\"timeout\""));
295    }
296
297    #[tokio::test]
298    async fn audit_logger_stdout() {
299        let config = AuditConfig {
300            enabled: true,
301            destination: "stdout".into(),
302            ..Default::default()
303        };
304        let logger = AuditLogger::from_config(&config).await.unwrap();
305        let entry = AuditEntry {
306            timestamp: "0".into(),
307            tool: "shell".into(),
308            command: "echo test".into(),
309            result: AuditResult::Success,
310            duration_ms: 1,
311            error_category: None,
312            error_domain: None,
313            error_phase: None,
314            claim_source: None,
315            mcp_server_id: None,
316            injection_flagged: false,
317            embedding_anomalous: false,
318            cross_boundary_mcp_to_acp: false,
319            adversarial_policy_decision: None,
320            exit_code: None,
321            truncated: false,
322            policy_match: None,
323            caller_id: None,
324        };
325        logger.log(&entry).await;
326    }
327
328    #[tokio::test]
329    async fn audit_logger_file() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = dir.path().join("audit.log");
332        let config = AuditConfig {
333            enabled: true,
334            destination: path.display().to_string(),
335            ..Default::default()
336        };
337        let logger = AuditLogger::from_config(&config).await.unwrap();
338        let entry = AuditEntry {
339            timestamp: "0".into(),
340            tool: "shell".into(),
341            command: "echo test".into(),
342            result: AuditResult::Success,
343            duration_ms: 1,
344            error_category: None,
345            error_domain: None,
346            error_phase: None,
347            claim_source: None,
348            mcp_server_id: None,
349            injection_flagged: false,
350            embedding_anomalous: false,
351            cross_boundary_mcp_to_acp: false,
352            adversarial_policy_decision: None,
353            exit_code: None,
354            truncated: false,
355            policy_match: None,
356            caller_id: None,
357        };
358        logger.log(&entry).await;
359
360        let content = tokio::fs::read_to_string(&path).await.unwrap();
361        assert!(content.contains("\"tool\":\"shell\""));
362    }
363
364    #[tokio::test]
365    async fn audit_logger_file_write_error_logged() {
366        let config = AuditConfig {
367            enabled: true,
368            destination: "/nonexistent/dir/audit.log".into(),
369            ..Default::default()
370        };
371        let result = AuditLogger::from_config(&config).await;
372        assert!(result.is_err());
373    }
374
375    #[test]
376    fn claim_source_serde_roundtrip() {
377        use crate::executor::ClaimSource;
378        let cases = [
379            (ClaimSource::Shell, "\"shell\""),
380            (ClaimSource::FileSystem, "\"file_system\""),
381            (ClaimSource::WebScrape, "\"web_scrape\""),
382            (ClaimSource::Mcp, "\"mcp\""),
383            (ClaimSource::A2a, "\"a2a\""),
384            (ClaimSource::CodeSearch, "\"code_search\""),
385            (ClaimSource::Diagnostics, "\"diagnostics\""),
386            (ClaimSource::Memory, "\"memory\""),
387        ];
388        for (variant, expected_json) in cases {
389            let serialized = serde_json::to_string(&variant).unwrap();
390            assert_eq!(serialized, expected_json, "serialize {variant:?}");
391            let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
392            assert_eq!(deserialized, variant, "deserialize {variant:?}");
393        }
394    }
395
396    #[test]
397    fn audit_entry_claim_source_none_omitted() {
398        let entry = AuditEntry {
399            timestamp: "0".into(),
400            tool: "shell".into(),
401            command: "echo".into(),
402            result: AuditResult::Success,
403            duration_ms: 1,
404            error_category: None,
405            error_domain: None,
406            error_phase: None,
407            claim_source: None,
408            mcp_server_id: None,
409            injection_flagged: false,
410            embedding_anomalous: false,
411            cross_boundary_mcp_to_acp: false,
412            adversarial_policy_decision: None,
413            exit_code: None,
414            truncated: false,
415            policy_match: None,
416            caller_id: None,
417        };
418        let json = serde_json::to_string(&entry).unwrap();
419        assert!(
420            !json.contains("claim_source"),
421            "claim_source must be omitted when None: {json}"
422        );
423    }
424
425    #[test]
426    fn audit_entry_claim_source_some_present() {
427        use crate::executor::ClaimSource;
428        let entry = AuditEntry {
429            timestamp: "0".into(),
430            tool: "shell".into(),
431            command: "echo".into(),
432            result: AuditResult::Success,
433            duration_ms: 1,
434            error_category: None,
435            error_domain: None,
436            error_phase: None,
437            claim_source: Some(ClaimSource::Shell),
438            mcp_server_id: None,
439            injection_flagged: false,
440            embedding_anomalous: false,
441            cross_boundary_mcp_to_acp: false,
442            adversarial_policy_decision: None,
443            exit_code: None,
444            truncated: false,
445            policy_match: None,
446            caller_id: None,
447        };
448        let json = serde_json::to_string(&entry).unwrap();
449        assert!(
450            json.contains("\"claim_source\":\"shell\""),
451            "expected claim_source=shell in JSON: {json}"
452        );
453    }
454
455    #[tokio::test]
456    async fn audit_logger_multiple_entries() {
457        let dir = tempfile::tempdir().unwrap();
458        let path = dir.path().join("audit.log");
459        let config = AuditConfig {
460            enabled: true,
461            destination: path.display().to_string(),
462            ..Default::default()
463        };
464        let logger = AuditLogger::from_config(&config).await.unwrap();
465
466        for i in 0..5 {
467            let entry = AuditEntry {
468                timestamp: i.to_string(),
469                tool: "shell".into(),
470                command: format!("cmd{i}"),
471                result: AuditResult::Success,
472                duration_ms: i,
473                error_category: None,
474                error_domain: None,
475                error_phase: None,
476                claim_source: None,
477                mcp_server_id: None,
478                injection_flagged: false,
479                embedding_anomalous: false,
480                cross_boundary_mcp_to_acp: false,
481                adversarial_policy_decision: None,
482                exit_code: None,
483                truncated: false,
484                policy_match: None,
485                caller_id: None,
486            };
487            logger.log(&entry).await;
488        }
489
490        let content = tokio::fs::read_to_string(&path).await.unwrap();
491        assert_eq!(content.lines().count(), 5);
492    }
493
494    #[test]
495    fn audit_entry_exit_code_serialized() {
496        let entry = AuditEntry {
497            timestamp: "0".into(),
498            tool: "shell".into(),
499            command: "echo hi".into(),
500            result: AuditResult::Success,
501            duration_ms: 5,
502            error_category: None,
503            error_domain: None,
504            error_phase: None,
505            claim_source: None,
506            mcp_server_id: None,
507            injection_flagged: false,
508            embedding_anomalous: false,
509            cross_boundary_mcp_to_acp: false,
510            adversarial_policy_decision: None,
511            exit_code: Some(0),
512            truncated: false,
513            policy_match: None,
514            caller_id: None,
515        };
516        let json = serde_json::to_string(&entry).unwrap();
517        assert!(
518            json.contains("\"exit_code\":0"),
519            "exit_code must be serialized: {json}"
520        );
521    }
522
523    #[test]
524    fn audit_entry_exit_code_none_omitted() {
525        let entry = AuditEntry {
526            timestamp: "0".into(),
527            tool: "file".into(),
528            command: "read /tmp/x".into(),
529            result: AuditResult::Success,
530            duration_ms: 1,
531            error_category: None,
532            error_domain: None,
533            error_phase: None,
534            claim_source: None,
535            mcp_server_id: None,
536            injection_flagged: false,
537            embedding_anomalous: false,
538            cross_boundary_mcp_to_acp: false,
539            adversarial_policy_decision: None,
540            exit_code: None,
541            truncated: false,
542            policy_match: None,
543            caller_id: None,
544        };
545        let json = serde_json::to_string(&entry).unwrap();
546        assert!(
547            !json.contains("exit_code"),
548            "exit_code None must be omitted: {json}"
549        );
550    }
551
552    #[test]
553    fn log_tool_risk_summary_does_not_panic() {
554        log_tool_risk_summary(&[
555            "shell",
556            "bash",
557            "exec",
558            "web_scrape",
559            "fetch",
560            "scrape_page",
561            "file_write",
562            "file_read",
563            "file_delete",
564            "memory_search",
565            "unknown_tool",
566        ]);
567    }
568
569    #[test]
570    fn log_tool_risk_summary_empty_input_does_not_panic() {
571        log_tool_risk_summary(&[]);
572    }
573}