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
4//! Structured JSONL audit logging for tool invocations.
5//!
6//! Every tool execution produces an [`AuditEntry`] that is serialized as a newline-delimited
7//! JSON record and written to the configured destination (stdout or a file).
8//!
9//! # Configuration
10//!
11//! Audit logging is controlled by [`AuditConfig`]. When
12//! `destination` is `"stdout"`, entries are emitted via `tracing::info!(target: "audit", ...)`.
13//! Any other value is treated as a file path opened in append mode.
14//!
15//! # Security note
16//!
17//! Audit entries intentionally omit the raw cosine distance from anomaly detection
18//! (`embedding_anomalous` is a boolean flag) to prevent threshold reverse-engineering.
19
20use std::path::Path;
21
22use zeph_common::ToolName;
23
24use crate::config::AuditConfig;
25
26#[allow(clippy::trivially_copy_pass_by_ref)]
27fn is_zero_u8(v: &u8) -> bool {
28    *v == 0
29}
30
31/// Outbound network call record emitted by HTTP-capable executors.
32///
33/// Serialized as a JSON Lines record onto the shared audit sink. Consumers
34/// distinguish this record from [`AuditEntry`] by the presence of the `kind`
35/// field (always `"egress"`).
36///
37/// # Example JSON output
38///
39/// ```json
40/// {"timestamp":"1712345678","kind":"egress","correlation_id":"a1b2c3d4-...","tool":"fetch",
41///  "url":"https://example.com","host":"example.com","method":"GET","status":200,
42///  "duration_ms":120,"response_bytes":4096}
43/// ```
44#[derive(Debug, Clone, serde::Serialize)]
45pub struct EgressEvent {
46    /// Unix timestamp (seconds) when the request was issued.
47    pub timestamp: String,
48    /// Record-type discriminator — always `"egress"`. Consumers distinguish
49    /// `EgressEvent` from `AuditEntry` by the presence of this field.
50    pub kind: &'static str,
51    /// Correlation id shared with the parent [`AuditEntry`] (`UUIDv4`, lowercased).
52    pub correlation_id: String,
53    /// Tool that issued the call (`"web_scrape"`, `"fetch"`, …).
54    pub tool: ToolName,
55    /// Destination URL (after SSRF/domain validation).
56    pub url: String,
57    /// Hostname, denormalized for TUI aggregation.
58    pub host: String,
59    /// HTTP method (`"GET"`, `"POST"`, …).
60    pub method: String,
61    /// HTTP response status. `None` when the request failed pre-response.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub status: Option<u16>,
64    /// Wall-clock duration from send to end-of-body, in milliseconds.
65    pub duration_ms: u64,
66    /// Bytes of response body received. Zero on pre-response failure or
67    /// when `log_response_bytes = false`.
68    pub response_bytes: usize,
69    /// Whether the request was blocked before connection.
70    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
71    pub blocked: bool,
72    /// Block reason: `"allowlist"` | `"blocklist"` | `"ssrf"` | `"scheme"`.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub block_reason: Option<&'static str>,
75    /// Caller identity propagated from `ToolCall::caller_id`.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub caller_id: Option<String>,
78    /// Redirect hop index (0 for the initial request). Distinguishes per-hop events
79    /// sharing the same `correlation_id`.
80    #[serde(default, skip_serializing_if = "is_zero_u8")]
81    pub hop: u8,
82}
83
84impl EgressEvent {
85    /// Generate a new `UUIDv4` correlation id for use across a tool call's egress events.
86    #[must_use]
87    pub fn new_correlation_id() -> String {
88        uuid::Uuid::new_v4().to_string()
89    }
90}
91
92/// Async writer that appends [`AuditEntry`] records to a structured JSONL log.
93///
94/// Create via [`AuditLogger::from_config`] and share behind an `Arc`. Each executor
95/// that should emit audit records accepts the logger via a builder method
96/// (e.g. [`ShellExecutor::with_audit`](crate::ShellExecutor::with_audit)).
97///
98/// # Thread safety
99///
100/// File writes are serialized through an internal `tokio::sync::Mutex<File>`.
101/// Multiple concurrent log calls are safe but may block briefly on the mutex.
102#[derive(Debug)]
103pub struct AuditLogger {
104    destination: AuditDestination,
105}
106
107#[derive(Debug)]
108enum AuditDestination {
109    Stdout,
110    File(tokio::sync::Mutex<tokio::fs::File>),
111}
112
113/// A single tool invocation record written to the audit log.
114///
115/// Serialized as a flat JSON object (newline-terminated). Optional fields are omitted
116/// when `None` or `false` to keep entries compact.
117///
118/// # Example JSON output
119///
120/// ```json
121/// {"timestamp":"1712345678","tool":"shell","command":"ls -la","result":{"type":"success"},
122///  "duration_ms":12,"exit_code":0,"claim_source":"shell"}
123/// ```
124#[derive(serde::Serialize)]
125#[allow(clippy::struct_excessive_bools)]
126pub struct AuditEntry {
127    /// Unix timestamp (seconds) when the tool invocation started.
128    pub timestamp: String,
129    /// Tool identifier (e.g. `"shell"`, `"web_scrape"`, `"fetch"`).
130    pub tool: ToolName,
131    /// Human-readable command or URL being invoked.
132    pub command: String,
133    /// Outcome of the invocation.
134    pub result: AuditResult,
135    /// Wall-clock duration from invocation start to completion, in milliseconds.
136    pub duration_ms: u64,
137    /// Fine-grained error category label from the taxonomy. `None` for successful executions.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub error_category: Option<String>,
140    /// High-level error domain for recovery dispatch. `None` for successful executions.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub error_domain: Option<String>,
143    /// Invocation phase in which the error occurred per arXiv:2601.16280 taxonomy.
144    /// `None` for successful executions.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub error_phase: Option<String>,
147    /// Provenance of the tool result. `None` for non-executor audit entries (e.g. policy checks).
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub claim_source: Option<crate::executor::ClaimSource>,
150    /// MCP server ID for tool calls routed through `McpToolExecutor`. `None` for native tools.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub mcp_server_id: Option<String>,
153    /// Tool output was flagged by regex injection detection.
154    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
155    pub injection_flagged: bool,
156    /// Tool output was flagged as anomalous by the embedding guard.
157    /// Raw cosine distance is NOT stored (prevents threshold reverse-engineering).
158    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
159    pub embedding_anomalous: bool,
160    /// Tool result crossed the MCP-to-ACP trust boundary (MCP tool result served to an ACP client).
161    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162    pub cross_boundary_mcp_to_acp: bool,
163    /// Decision recorded by the adversarial policy agent before execution.
164    ///
165    /// Values: `"allow"`, `"deny:<reason>"`, `"error:<message>"`.
166    /// `None` when adversarial policy is disabled or not applicable.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub adversarial_policy_decision: Option<String>,
169    /// Process exit code for shell tool executions. `None` for non-shell tools.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub exit_code: Option<i32>,
172    /// Whether tool output was truncated before storage. Default false.
173    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
174    pub truncated: bool,
175    /// Caller identity that initiated this tool call. `None` for system calls.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub caller_id: Option<String>,
178    /// Policy rule trace that matched this tool call. Populated from `PolicyDecision::trace`.
179    /// `None` when policy is disabled or this entry is not from a policy check.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub policy_match: Option<String>,
182    /// Correlation id shared with any associated [`EgressEvent`] emitted during this
183    /// tool call. Generated at `execute_tool_call` entry. `None` for policy-only or
184    /// rollback entries that do not map to a network-capable tool call.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub correlation_id: Option<String>,
187    /// VIGIL risk level when the pre-sanitizer gate flagged this tool output.
188    /// `None` when VIGIL did not fire (output was clean or tool was exempt).
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub vigil_risk: Option<VigilRiskLevel>,
191}
192
193/// Risk level assigned by the VIGIL pre-sanitizer gate to a flagged tool output.
194///
195/// Emitted in [`AuditEntry::vigil_risk`] when VIGIL fires.
196/// Colocated with `AuditEntry` so the audit JSONL schema is self-contained.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
198#[serde(rename_all = "lowercase")]
199pub enum VigilRiskLevel {
200    /// Reserved for future use: heuristic match below the primary threshold.
201    Low,
202    /// Single-pattern match in non-strict mode.
203    Medium,
204    /// ≥2 distinct pattern categories OR `strict_mode = true`.
205    High,
206}
207
208/// Outcome of a tool invocation, serialized as a tagged JSON object.
209///
210/// The `type` field selects the variant; additional fields are present only for the
211/// relevant variants.
212///
213/// # Serialization
214///
215/// ```json
216/// {"type":"success"}
217/// {"type":"blocked","reason":"sudo"}
218/// {"type":"error","message":"exec failed"}
219/// {"type":"timeout"}
220/// {"type":"rollback","restored":3,"deleted":1}
221/// ```
222#[derive(serde::Serialize)]
223#[serde(tag = "type")]
224pub enum AuditResult {
225    /// The tool executed successfully.
226    #[serde(rename = "success")]
227    Success,
228    /// The tool invocation was blocked by policy before execution.
229    #[serde(rename = "blocked")]
230    Blocked {
231        /// The matched blocklist pattern or policy rule that triggered the block.
232        reason: String,
233    },
234    /// The tool attempted execution but failed with an error.
235    #[serde(rename = "error")]
236    Error {
237        /// Human-readable error description.
238        message: String,
239    },
240    /// The tool exceeded its configured timeout.
241    #[serde(rename = "timeout")]
242    Timeout,
243    /// A transactional rollback was performed after a failed execution.
244    #[serde(rename = "rollback")]
245    Rollback {
246        /// Number of files restored to their pre-execution snapshot.
247        restored: usize,
248        /// Number of newly-created files that were deleted during rollback.
249        deleted: usize,
250    },
251}
252
253impl AuditLogger {
254    /// Create a new `AuditLogger` from config.
255    ///
256    /// When `tui_mode` is `true` and `config.destination` is `"stdout"`, the
257    /// destination is redirected to a file (`audit.jsonl` in the current directory)
258    /// to avoid corrupting the TUI output with raw JSON lines.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if a file destination cannot be opened.
263    #[allow(clippy::unused_async)]
264    pub async fn from_config(config: &AuditConfig, tui_mode: bool) -> Result<Self, std::io::Error> {
265        let effective_dest = if tui_mode && config.destination == "stdout" {
266            tracing::warn!("TUI mode: audit stdout redirected to file audit.jsonl");
267            "audit.jsonl".to_owned()
268        } else {
269            config.destination.clone()
270        };
271
272        let destination = if effective_dest == "stdout" {
273            AuditDestination::Stdout
274        } else {
275            let std_file = zeph_common::fs_secure::append_private(Path::new(&effective_dest))?;
276            let file = tokio::fs::File::from_std(std_file);
277            AuditDestination::File(tokio::sync::Mutex::new(file))
278        };
279
280        Ok(Self { destination })
281    }
282
283    /// Serialize `entry` to JSON and append it to the configured destination.
284    ///
285    /// Serialization errors are logged via `tracing::error!` and silently swallowed so
286    /// that audit failures never interrupt tool execution.
287    pub async fn log(&self, entry: &AuditEntry) {
288        let json = match serde_json::to_string(entry) {
289            Ok(j) => j,
290            Err(err) => {
291                tracing::error!("audit entry serialization failed: {err}");
292                return;
293            }
294        };
295
296        match &self.destination {
297            AuditDestination::Stdout => {
298                tracing::info!(target: "audit", "{json}");
299            }
300            AuditDestination::File(file) => {
301                use tokio::io::AsyncWriteExt;
302                let mut f = file.lock().await;
303                let line = format!("{json}\n");
304                if let Err(e) = f.write_all(line.as_bytes()).await {
305                    tracing::error!("failed to write audit log: {e}");
306                } else if let Err(e) = f.flush().await {
307                    tracing::error!("failed to flush audit log: {e}");
308                }
309            }
310        }
311    }
312
313    /// Serialize an [`EgressEvent`] onto the same JSONL destination as [`AuditEntry`].
314    ///
315    /// Ordering with respect to [`AuditLogger::log`] is preserved by the shared
316    /// `tokio::sync::Mutex<File>` that serializes all writes on the same destination.
317    ///
318    /// Serialization errors are logged via `tracing::error!` and silently swallowed
319    /// so that egress logging failures never interrupt tool execution.
320    pub async fn log_egress(&self, event: &EgressEvent) {
321        let json = match serde_json::to_string(event) {
322            Ok(j) => j,
323            Err(err) => {
324                tracing::error!("egress event serialization failed: {err}");
325                return;
326            }
327        };
328
329        match &self.destination {
330            AuditDestination::Stdout => {
331                tracing::info!(target: "audit", "{json}");
332            }
333            AuditDestination::File(file) => {
334                use tokio::io::AsyncWriteExt;
335                let mut f = file.lock().await;
336                let line = format!("{json}\n");
337                if let Err(e) = f.write_all(line.as_bytes()).await {
338                    tracing::error!("failed to write egress log: {e}");
339                } else if let Err(e) = f.flush().await {
340                    tracing::error!("failed to flush egress log: {e}");
341                }
342            }
343        }
344    }
345}
346
347/// Log a per-tool risk summary at startup when `audit.tool_risk_summary = true`.
348///
349/// Each entry records tool name, privilege level (static mapping by tool id), and the
350/// expected input sanitization method. This is a design-time inventory label —
351/// NOT a runtime guarantee that sanitization is functioning correctly.
352pub fn log_tool_risk_summary(tool_ids: &[&str]) {
353    // Static privilege mapping: tool id prefix → (privilege level, expected sanitization).
354    // "high" = can execute arbitrary OS commands; "medium" = network/filesystem access;
355    // "low" = schema-validated parameters only.
356    fn classify(id: &str) -> (&'static str, &'static str) {
357        if id.starts_with("shell") || id == "bash" || id == "exec" {
358            ("high", "env_blocklist + command_blocklist")
359        } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
360            ("medium", "validate_url + SSRF + domain_policy")
361        } else if id.starts_with("file_write")
362            || id.starts_with("file_read")
363            || id.starts_with("file")
364        {
365            ("medium", "path_sandbox")
366        } else {
367            ("low", "schema_only")
368        }
369    }
370
371    for &id in tool_ids {
372        let (privilege, sanitization) = classify(id);
373        tracing::info!(
374            tool = id,
375            privilege_level = privilege,
376            expected_sanitization = sanitization,
377            "tool risk summary"
378        );
379    }
380}
381
382/// Returns the current Unix timestamp as a decimal string.
383///
384/// Used to populate [`AuditEntry::timestamp`]. Returns `"0"` if the system clock
385/// is before the Unix epoch (which should never happen in practice).
386#[must_use]
387pub fn chrono_now() -> String {
388    use std::time::{SystemTime, UNIX_EPOCH};
389    let secs = SystemTime::now()
390        .duration_since(UNIX_EPOCH)
391        .unwrap_or_default()
392        .as_secs();
393    format!("{secs}")
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn audit_entry_serialization() {
402        let entry = AuditEntry {
403            timestamp: "1234567890".into(),
404            tool: "shell".into(),
405            command: "echo hello".into(),
406            result: AuditResult::Success,
407            duration_ms: 42,
408            error_category: None,
409            error_domain: None,
410            error_phase: None,
411            claim_source: None,
412            mcp_server_id: None,
413            injection_flagged: false,
414            embedding_anomalous: false,
415            cross_boundary_mcp_to_acp: false,
416            adversarial_policy_decision: None,
417            exit_code: None,
418            truncated: false,
419            policy_match: None,
420            correlation_id: None,
421            caller_id: None,
422            vigil_risk: None,
423        };
424        let json = serde_json::to_string(&entry).unwrap();
425        assert!(json.contains("\"type\":\"success\""));
426        assert!(json.contains("\"tool\":\"shell\""));
427        assert!(json.contains("\"duration_ms\":42"));
428    }
429
430    #[test]
431    fn audit_result_blocked_serialization() {
432        let entry = AuditEntry {
433            timestamp: "0".into(),
434            tool: "shell".into(),
435            command: "sudo rm".into(),
436            result: AuditResult::Blocked {
437                reason: "blocked command: sudo".into(),
438            },
439            duration_ms: 0,
440            error_category: Some("policy_blocked".to_owned()),
441            error_domain: Some("action".to_owned()),
442            error_phase: None,
443            claim_source: None,
444            mcp_server_id: None,
445            injection_flagged: false,
446            embedding_anomalous: false,
447            cross_boundary_mcp_to_acp: false,
448            adversarial_policy_decision: None,
449            exit_code: None,
450            truncated: false,
451            policy_match: None,
452            correlation_id: None,
453            caller_id: None,
454            vigil_risk: None,
455        };
456        let json = serde_json::to_string(&entry).unwrap();
457        assert!(json.contains("\"type\":\"blocked\""));
458        assert!(json.contains("\"reason\""));
459    }
460
461    #[test]
462    fn audit_result_error_serialization() {
463        let entry = AuditEntry {
464            timestamp: "0".into(),
465            tool: "shell".into(),
466            command: "bad".into(),
467            result: AuditResult::Error {
468                message: "exec failed".into(),
469            },
470            duration_ms: 0,
471            error_category: None,
472            error_domain: None,
473            error_phase: None,
474            claim_source: None,
475            mcp_server_id: None,
476            injection_flagged: false,
477            embedding_anomalous: false,
478            cross_boundary_mcp_to_acp: false,
479            adversarial_policy_decision: None,
480            exit_code: None,
481            truncated: false,
482            policy_match: None,
483            correlation_id: None,
484            caller_id: None,
485            vigil_risk: None,
486        };
487        let json = serde_json::to_string(&entry).unwrap();
488        assert!(json.contains("\"type\":\"error\""));
489    }
490
491    #[test]
492    fn audit_result_timeout_serialization() {
493        let entry = AuditEntry {
494            timestamp: "0".into(),
495            tool: "shell".into(),
496            command: "sleep 999".into(),
497            result: AuditResult::Timeout,
498            duration_ms: 30000,
499            error_category: Some("timeout".to_owned()),
500            error_domain: Some("system".to_owned()),
501            error_phase: None,
502            claim_source: None,
503            mcp_server_id: None,
504            injection_flagged: false,
505            embedding_anomalous: false,
506            cross_boundary_mcp_to_acp: false,
507            adversarial_policy_decision: None,
508            exit_code: None,
509            truncated: false,
510            policy_match: None,
511            correlation_id: None,
512            caller_id: None,
513            vigil_risk: None,
514        };
515        let json = serde_json::to_string(&entry).unwrap();
516        assert!(json.contains("\"type\":\"timeout\""));
517    }
518
519    #[tokio::test]
520    async fn audit_logger_stdout() {
521        let config = AuditConfig {
522            enabled: true,
523            destination: "stdout".into(),
524            ..Default::default()
525        };
526        let logger = AuditLogger::from_config(&config, false).await.unwrap();
527        let entry = AuditEntry {
528            timestamp: "0".into(),
529            tool: "shell".into(),
530            command: "echo test".into(),
531            result: AuditResult::Success,
532            duration_ms: 1,
533            error_category: None,
534            error_domain: None,
535            error_phase: None,
536            claim_source: None,
537            mcp_server_id: None,
538            injection_flagged: false,
539            embedding_anomalous: false,
540            cross_boundary_mcp_to_acp: false,
541            adversarial_policy_decision: None,
542            exit_code: None,
543            truncated: false,
544            policy_match: None,
545            correlation_id: None,
546            caller_id: None,
547            vigil_risk: None,
548        };
549        logger.log(&entry).await;
550    }
551
552    #[tokio::test]
553    async fn audit_logger_file() {
554        let dir = tempfile::tempdir().unwrap();
555        let path = dir.path().join("audit.log");
556        let config = AuditConfig {
557            enabled: true,
558            destination: path.display().to_string(),
559            ..Default::default()
560        };
561        let logger = AuditLogger::from_config(&config, false).await.unwrap();
562        let entry = AuditEntry {
563            timestamp: "0".into(),
564            tool: "shell".into(),
565            command: "echo test".into(),
566            result: AuditResult::Success,
567            duration_ms: 1,
568            error_category: None,
569            error_domain: None,
570            error_phase: None,
571            claim_source: None,
572            mcp_server_id: None,
573            injection_flagged: false,
574            embedding_anomalous: false,
575            cross_boundary_mcp_to_acp: false,
576            adversarial_policy_decision: None,
577            exit_code: None,
578            truncated: false,
579            policy_match: None,
580            correlation_id: None,
581            caller_id: None,
582            vigil_risk: None,
583        };
584        logger.log(&entry).await;
585
586        let content = tokio::fs::read_to_string(&path).await.unwrap();
587        assert!(content.contains("\"tool\":\"shell\""));
588    }
589
590    #[tokio::test]
591    async fn audit_logger_file_write_error_logged() {
592        let config = AuditConfig {
593            enabled: true,
594            destination: "/nonexistent/dir/audit.log".into(),
595            ..Default::default()
596        };
597        let result = AuditLogger::from_config(&config, false).await;
598        assert!(result.is_err());
599    }
600
601    #[test]
602    fn claim_source_serde_roundtrip() {
603        use crate::executor::ClaimSource;
604        let cases = [
605            (ClaimSource::Shell, "\"shell\""),
606            (ClaimSource::FileSystem, "\"file_system\""),
607            (ClaimSource::WebScrape, "\"web_scrape\""),
608            (ClaimSource::Mcp, "\"mcp\""),
609            (ClaimSource::A2a, "\"a2a\""),
610            (ClaimSource::CodeSearch, "\"code_search\""),
611            (ClaimSource::Diagnostics, "\"diagnostics\""),
612            (ClaimSource::Memory, "\"memory\""),
613        ];
614        for (variant, expected_json) in cases {
615            let serialized = serde_json::to_string(&variant).unwrap();
616            assert_eq!(serialized, expected_json, "serialize {variant:?}");
617            let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
618            assert_eq!(deserialized, variant, "deserialize {variant:?}");
619        }
620    }
621
622    #[test]
623    fn audit_entry_claim_source_none_omitted() {
624        let entry = AuditEntry {
625            timestamp: "0".into(),
626            tool: "shell".into(),
627            command: "echo".into(),
628            result: AuditResult::Success,
629            duration_ms: 1,
630            error_category: None,
631            error_domain: None,
632            error_phase: None,
633            claim_source: None,
634            mcp_server_id: None,
635            injection_flagged: false,
636            embedding_anomalous: false,
637            cross_boundary_mcp_to_acp: false,
638            adversarial_policy_decision: None,
639            exit_code: None,
640            truncated: false,
641            policy_match: None,
642            correlation_id: None,
643            caller_id: None,
644            vigil_risk: None,
645        };
646        let json = serde_json::to_string(&entry).unwrap();
647        assert!(
648            !json.contains("claim_source"),
649            "claim_source must be omitted when None: {json}"
650        );
651    }
652
653    #[test]
654    fn audit_entry_claim_source_some_present() {
655        use crate::executor::ClaimSource;
656        let entry = AuditEntry {
657            timestamp: "0".into(),
658            tool: "shell".into(),
659            command: "echo".into(),
660            result: AuditResult::Success,
661            duration_ms: 1,
662            error_category: None,
663            error_domain: None,
664            error_phase: None,
665            claim_source: Some(ClaimSource::Shell),
666            mcp_server_id: None,
667            injection_flagged: false,
668            embedding_anomalous: false,
669            cross_boundary_mcp_to_acp: false,
670            adversarial_policy_decision: None,
671            exit_code: None,
672            truncated: false,
673            policy_match: None,
674            correlation_id: None,
675            caller_id: None,
676            vigil_risk: None,
677        };
678        let json = serde_json::to_string(&entry).unwrap();
679        assert!(
680            json.contains("\"claim_source\":\"shell\""),
681            "expected claim_source=shell in JSON: {json}"
682        );
683    }
684
685    #[tokio::test]
686    async fn audit_logger_multiple_entries() {
687        let dir = tempfile::tempdir().unwrap();
688        let path = dir.path().join("audit.log");
689        let config = AuditConfig {
690            enabled: true,
691            destination: path.display().to_string(),
692            ..Default::default()
693        };
694        let logger = AuditLogger::from_config(&config, false).await.unwrap();
695
696        for i in 0..5 {
697            let entry = AuditEntry {
698                timestamp: i.to_string(),
699                tool: "shell".into(),
700                command: format!("cmd{i}"),
701                result: AuditResult::Success,
702                duration_ms: i,
703                error_category: None,
704                error_domain: None,
705                error_phase: None,
706                claim_source: None,
707                mcp_server_id: None,
708                injection_flagged: false,
709                embedding_anomalous: false,
710                cross_boundary_mcp_to_acp: false,
711                adversarial_policy_decision: None,
712                exit_code: None,
713                truncated: false,
714                policy_match: None,
715                correlation_id: None,
716                caller_id: None,
717                vigil_risk: None,
718            };
719            logger.log(&entry).await;
720        }
721
722        let content = tokio::fs::read_to_string(&path).await.unwrap();
723        assert_eq!(content.lines().count(), 5);
724    }
725
726    #[test]
727    fn audit_entry_exit_code_serialized() {
728        let entry = AuditEntry {
729            timestamp: "0".into(),
730            tool: "shell".into(),
731            command: "echo hi".into(),
732            result: AuditResult::Success,
733            duration_ms: 5,
734            error_category: None,
735            error_domain: None,
736            error_phase: None,
737            claim_source: None,
738            mcp_server_id: None,
739            injection_flagged: false,
740            embedding_anomalous: false,
741            cross_boundary_mcp_to_acp: false,
742            adversarial_policy_decision: None,
743            exit_code: Some(0),
744            truncated: false,
745            policy_match: None,
746            correlation_id: None,
747            caller_id: None,
748            vigil_risk: None,
749        };
750        let json = serde_json::to_string(&entry).unwrap();
751        assert!(
752            json.contains("\"exit_code\":0"),
753            "exit_code must be serialized: {json}"
754        );
755    }
756
757    #[test]
758    fn audit_entry_exit_code_none_omitted() {
759        let entry = AuditEntry {
760            timestamp: "0".into(),
761            tool: "file".into(),
762            command: "read /tmp/x".into(),
763            result: AuditResult::Success,
764            duration_ms: 1,
765            error_category: None,
766            error_domain: None,
767            error_phase: None,
768            claim_source: None,
769            mcp_server_id: None,
770            injection_flagged: false,
771            embedding_anomalous: false,
772            cross_boundary_mcp_to_acp: false,
773            adversarial_policy_decision: None,
774            exit_code: None,
775            truncated: false,
776            policy_match: None,
777            correlation_id: None,
778            caller_id: None,
779            vigil_risk: None,
780        };
781        let json = serde_json::to_string(&entry).unwrap();
782        assert!(
783            !json.contains("exit_code"),
784            "exit_code None must be omitted: {json}"
785        );
786    }
787
788    #[test]
789    fn log_tool_risk_summary_does_not_panic() {
790        log_tool_risk_summary(&[
791            "shell",
792            "bash",
793            "exec",
794            "web_scrape",
795            "fetch",
796            "scrape_page",
797            "file_write",
798            "file_read",
799            "file_delete",
800            "memory_search",
801            "unknown_tool",
802        ]);
803    }
804
805    #[test]
806    fn log_tool_risk_summary_empty_input_does_not_panic() {
807        log_tool_risk_summary(&[]);
808    }
809}