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)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
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    /// Name of the resolved execution environment (from `[[execution.environments]]`).
192    /// `None` when no named environment was selected for this invocation.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub execution_env: Option<String>,
195    /// Canonical absolute working directory actually used for this shell invocation.
196    /// `None` for non-shell tools or legacy path without a resolved context.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub resolved_cwd: Option<String>,
199    /// Name of the capability scope active at `tool_definitions()` time (for scope-at-definition audit).
200    /// `None` when `ScopedToolExecutor` is not in the chain or the scope is the identity (`general`).
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub scope_at_definition: Option<String>,
203    /// Name of the capability scope active at `execute_tool_call()` dispatch time.
204    /// `None` when `ScopedToolExecutor` is not in the chain.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub scope_at_dispatch: Option<String>,
207}
208
209/// Risk level assigned by the VIGIL pre-sanitizer gate to a flagged tool output.
210///
211/// Emitted in [`AuditEntry::vigil_risk`] when VIGIL fires.
212/// Colocated with `AuditEntry` so the audit JSONL schema is self-contained.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
214#[serde(rename_all = "lowercase")]
215pub enum VigilRiskLevel {
216    /// Reserved for future use: heuristic match below the primary threshold.
217    Low,
218    /// Single-pattern match in non-strict mode.
219    Medium,
220    /// ≥2 distinct pattern categories OR `strict_mode = true`.
221    High,
222}
223
224/// Outcome of a tool invocation, serialized as a tagged JSON object.
225///
226/// The `type` field selects the variant; additional fields are present only for the
227/// relevant variants.
228///
229/// # Serialization
230///
231/// ```json
232/// {"type":"success"}
233/// {"type":"blocked","reason":"sudo"}
234/// {"type":"error","message":"exec failed"}
235/// {"type":"timeout"}
236/// {"type":"rollback","restored":3,"deleted":1}
237/// ```
238#[derive(serde::Serialize)]
239#[serde(tag = "type")]
240pub enum AuditResult {
241    /// The tool executed successfully.
242    #[serde(rename = "success")]
243    Success,
244    /// The tool invocation was blocked by policy before execution.
245    #[serde(rename = "blocked")]
246    Blocked {
247        /// The matched blocklist pattern or policy rule that triggered the block.
248        reason: String,
249    },
250    /// The tool attempted execution but failed with an error.
251    #[serde(rename = "error")]
252    Error {
253        /// Human-readable error description.
254        message: String,
255    },
256    /// The tool exceeded its configured timeout.
257    #[serde(rename = "timeout")]
258    Timeout,
259    /// A transactional rollback was performed after a failed execution.
260    #[serde(rename = "rollback")]
261    Rollback {
262        /// Number of files restored to their pre-execution snapshot.
263        restored: usize,
264        /// Number of newly-created files that were deleted during rollback.
265        deleted: usize,
266    },
267}
268
269impl AuditLogger {
270    /// Create a new `AuditLogger` from config.
271    ///
272    /// When `tui_mode` is `true` and `config.destination` is `"stdout"`, the
273    /// destination is redirected to a file (`audit.jsonl` in the current directory)
274    /// to avoid corrupting the TUI output with raw JSON lines.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if a file destination cannot be opened.
279    #[allow(clippy::unused_async)]
280    pub async fn from_config(config: &AuditConfig, tui_mode: bool) -> Result<Self, std::io::Error> {
281        let effective_dest = if tui_mode && config.destination == "stdout" {
282            tracing::warn!("TUI mode: audit stdout redirected to file audit.jsonl");
283            "audit.jsonl".to_owned()
284        } else {
285            config.destination.clone()
286        };
287
288        let destination = if effective_dest == "stdout" {
289            AuditDestination::Stdout
290        } else {
291            let std_file = zeph_common::fs_secure::append_private(Path::new(&effective_dest))?;
292            let file = tokio::fs::File::from_std(std_file);
293            AuditDestination::File(tokio::sync::Mutex::new(file))
294        };
295
296        Ok(Self { destination })
297    }
298
299    /// Serialize `entry` to JSON and append it to the configured destination.
300    ///
301    /// Serialization errors are logged via `tracing::error!` and silently swallowed so
302    /// that audit failures never interrupt tool execution.
303    pub async fn log(&self, entry: &AuditEntry) {
304        let json = match serde_json::to_string(entry) {
305            Ok(j) => j,
306            Err(err) => {
307                tracing::error!("audit entry serialization failed: {err}");
308                return;
309            }
310        };
311
312        match &self.destination {
313            AuditDestination::Stdout => {
314                tracing::info!(target: "audit", "{json}");
315            }
316            AuditDestination::File(file) => {
317                use tokio::io::AsyncWriteExt;
318                let mut f = file.lock().await;
319                let line = format!("{json}\n");
320                if let Err(e) = f.write_all(line.as_bytes()).await {
321                    tracing::error!("failed to write audit log: {e}");
322                } else if let Err(e) = f.flush().await {
323                    tracing::error!("failed to flush audit log: {e}");
324                }
325            }
326        }
327    }
328
329    /// Serialize an [`EgressEvent`] onto the same JSONL destination as [`AuditEntry`].
330    ///
331    /// Ordering with respect to [`AuditLogger::log`] is preserved by the shared
332    /// `tokio::sync::Mutex<File>` that serializes all writes on the same destination.
333    ///
334    /// Serialization errors are logged via `tracing::error!` and silently swallowed
335    /// so that egress logging failures never interrupt tool execution.
336    pub async fn log_egress(&self, event: &EgressEvent) {
337        let json = match serde_json::to_string(event) {
338            Ok(j) => j,
339            Err(err) => {
340                tracing::error!("egress event serialization failed: {err}");
341                return;
342            }
343        };
344
345        match &self.destination {
346            AuditDestination::Stdout => {
347                tracing::info!(target: "audit", "{json}");
348            }
349            AuditDestination::File(file) => {
350                use tokio::io::AsyncWriteExt;
351                let mut f = file.lock().await;
352                let line = format!("{json}\n");
353                if let Err(e) = f.write_all(line.as_bytes()).await {
354                    tracing::error!("failed to write egress log: {e}");
355                } else if let Err(e) = f.flush().await {
356                    tracing::error!("failed to flush egress log: {e}");
357                }
358            }
359        }
360    }
361}
362
363/// Log a per-tool risk summary at startup when `audit.tool_risk_summary = true`.
364///
365/// Each entry records tool name, privilege level (static mapping by tool id), and the
366/// expected input sanitization method. This is a design-time inventory label —
367/// NOT a runtime guarantee that sanitization is functioning correctly.
368pub fn log_tool_risk_summary(tool_ids: &[&str]) {
369    // Static privilege mapping: tool id prefix → (privilege level, expected sanitization).
370    // "high" = can execute arbitrary OS commands; "medium" = network/filesystem access;
371    // "low" = schema-validated parameters only.
372    fn classify(id: &str) -> (&'static str, &'static str) {
373        if id.starts_with("shell") || id == "bash" || id == "exec" {
374            ("high", "env_blocklist + command_blocklist")
375        } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
376            ("medium", "validate_url + SSRF + domain_policy")
377        } else if id.starts_with("file_write")
378            || id.starts_with("file_read")
379            || id.starts_with("file")
380        {
381            ("medium", "path_sandbox")
382        } else {
383            ("low", "schema_only")
384        }
385    }
386
387    for &id in tool_ids {
388        let (privilege, sanitization) = classify(id);
389        tracing::info!(
390            tool = id,
391            privilege_level = privilege,
392            expected_sanitization = sanitization,
393            "tool risk summary"
394        );
395    }
396}
397
398/// Returns the current Unix timestamp as a decimal string.
399///
400/// Used to populate [`AuditEntry::timestamp`]. Returns `"0"` if the system clock
401/// is before the Unix epoch (which should never happen in practice).
402#[must_use]
403pub fn chrono_now() -> String {
404    use std::time::{SystemTime, UNIX_EPOCH};
405    let secs = SystemTime::now()
406        .duration_since(UNIX_EPOCH)
407        .unwrap_or_default()
408        .as_secs();
409    format!("{secs}")
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn audit_entry_serialization() {
418        let entry = AuditEntry {
419            timestamp: "1234567890".into(),
420            tool: "shell".into(),
421            command: "echo hello".into(),
422            result: AuditResult::Success,
423            duration_ms: 42,
424            error_category: None,
425            error_domain: None,
426            error_phase: None,
427            claim_source: None,
428            mcp_server_id: None,
429            injection_flagged: false,
430            embedding_anomalous: false,
431            cross_boundary_mcp_to_acp: false,
432            adversarial_policy_decision: None,
433            exit_code: None,
434            truncated: false,
435            policy_match: None,
436            correlation_id: None,
437            caller_id: None,
438            vigil_risk: None,
439            execution_env: None,
440            resolved_cwd: None,
441            scope_at_definition: None,
442            scope_at_dispatch: None,
443        };
444        let json = serde_json::to_string(&entry).unwrap();
445        assert!(json.contains("\"type\":\"success\""));
446        assert!(json.contains("\"tool\":\"shell\""));
447        assert!(json.contains("\"duration_ms\":42"));
448    }
449
450    #[test]
451    fn audit_result_blocked_serialization() {
452        let entry = AuditEntry {
453            timestamp: "0".into(),
454            tool: "shell".into(),
455            command: "sudo rm".into(),
456            result: AuditResult::Blocked {
457                reason: "blocked command: sudo".into(),
458            },
459            duration_ms: 0,
460            error_category: Some("policy_blocked".to_owned()),
461            error_domain: Some("action".to_owned()),
462            error_phase: None,
463            claim_source: None,
464            mcp_server_id: None,
465            injection_flagged: false,
466            embedding_anomalous: false,
467            cross_boundary_mcp_to_acp: false,
468            adversarial_policy_decision: None,
469            exit_code: None,
470            truncated: false,
471            policy_match: None,
472            correlation_id: None,
473            caller_id: None,
474            vigil_risk: None,
475            execution_env: None,
476            resolved_cwd: None,
477            scope_at_definition: None,
478            scope_at_dispatch: None,
479        };
480        let json = serde_json::to_string(&entry).unwrap();
481        assert!(json.contains("\"type\":\"blocked\""));
482        assert!(json.contains("\"reason\""));
483    }
484
485    #[test]
486    fn audit_result_error_serialization() {
487        let entry = AuditEntry {
488            timestamp: "0".into(),
489            tool: "shell".into(),
490            command: "bad".into(),
491            result: AuditResult::Error {
492                message: "exec failed".into(),
493            },
494            duration_ms: 0,
495            error_category: None,
496            error_domain: None,
497            error_phase: None,
498            claim_source: None,
499            mcp_server_id: None,
500            injection_flagged: false,
501            embedding_anomalous: false,
502            cross_boundary_mcp_to_acp: false,
503            adversarial_policy_decision: None,
504            exit_code: None,
505            truncated: false,
506            policy_match: None,
507            correlation_id: None,
508            caller_id: None,
509            vigil_risk: None,
510            execution_env: None,
511            resolved_cwd: None,
512            scope_at_definition: None,
513            scope_at_dispatch: None,
514        };
515        let json = serde_json::to_string(&entry).unwrap();
516        assert!(json.contains("\"type\":\"error\""));
517    }
518
519    #[test]
520    fn audit_result_timeout_serialization() {
521        let entry = AuditEntry {
522            timestamp: "0".into(),
523            tool: "shell".into(),
524            command: "sleep 999".into(),
525            result: AuditResult::Timeout,
526            duration_ms: 30000,
527            error_category: Some("timeout".to_owned()),
528            error_domain: Some("system".to_owned()),
529            error_phase: None,
530            claim_source: None,
531            mcp_server_id: None,
532            injection_flagged: false,
533            embedding_anomalous: false,
534            cross_boundary_mcp_to_acp: false,
535            adversarial_policy_decision: None,
536            exit_code: None,
537            truncated: false,
538            policy_match: None,
539            correlation_id: None,
540            caller_id: None,
541            vigil_risk: None,
542            execution_env: None,
543            resolved_cwd: None,
544            scope_at_definition: None,
545            scope_at_dispatch: None,
546        };
547        let json = serde_json::to_string(&entry).unwrap();
548        assert!(json.contains("\"type\":\"timeout\""));
549    }
550
551    #[tokio::test]
552    async fn audit_logger_stdout() {
553        let config = AuditConfig {
554            enabled: true,
555            destination: "stdout".into(),
556            ..Default::default()
557        };
558        let logger = AuditLogger::from_config(&config, false).await.unwrap();
559        let entry = AuditEntry {
560            timestamp: "0".into(),
561            tool: "shell".into(),
562            command: "echo test".into(),
563            result: AuditResult::Success,
564            duration_ms: 1,
565            error_category: None,
566            error_domain: None,
567            error_phase: None,
568            claim_source: None,
569            mcp_server_id: None,
570            injection_flagged: false,
571            embedding_anomalous: false,
572            cross_boundary_mcp_to_acp: false,
573            adversarial_policy_decision: None,
574            exit_code: None,
575            truncated: false,
576            policy_match: None,
577            correlation_id: None,
578            caller_id: None,
579            vigil_risk: None,
580            execution_env: None,
581            resolved_cwd: None,
582            scope_at_definition: None,
583            scope_at_dispatch: None,
584        };
585        logger.log(&entry).await;
586    }
587
588    #[tokio::test]
589    async fn audit_logger_file() {
590        let dir = tempfile::tempdir().unwrap();
591        let path = dir.path().join("audit.log");
592        let config = AuditConfig {
593            enabled: true,
594            destination: path.display().to_string(),
595            ..Default::default()
596        };
597        let logger = AuditLogger::from_config(&config, false).await.unwrap();
598        let entry = AuditEntry {
599            timestamp: "0".into(),
600            tool: "shell".into(),
601            command: "echo test".into(),
602            result: AuditResult::Success,
603            duration_ms: 1,
604            error_category: None,
605            error_domain: None,
606            error_phase: None,
607            claim_source: None,
608            mcp_server_id: None,
609            injection_flagged: false,
610            embedding_anomalous: false,
611            cross_boundary_mcp_to_acp: false,
612            adversarial_policy_decision: None,
613            exit_code: None,
614            truncated: false,
615            policy_match: None,
616            correlation_id: None,
617            caller_id: None,
618            vigil_risk: None,
619            execution_env: None,
620            resolved_cwd: None,
621            scope_at_definition: None,
622            scope_at_dispatch: None,
623        };
624        logger.log(&entry).await;
625
626        let content = tokio::fs::read_to_string(&path).await.unwrap();
627        assert!(content.contains("\"tool\":\"shell\""));
628    }
629
630    #[tokio::test]
631    async fn audit_logger_file_write_error_logged() {
632        let config = AuditConfig {
633            enabled: true,
634            destination: "/nonexistent/dir/audit.log".into(),
635            ..Default::default()
636        };
637        let result = AuditLogger::from_config(&config, false).await;
638        assert!(result.is_err());
639    }
640
641    #[test]
642    fn claim_source_serde_roundtrip() {
643        use crate::executor::ClaimSource;
644        let cases = [
645            (ClaimSource::Shell, "\"shell\""),
646            (ClaimSource::FileSystem, "\"file_system\""),
647            (ClaimSource::WebScrape, "\"web_scrape\""),
648            (ClaimSource::Mcp, "\"mcp\""),
649            (ClaimSource::A2a, "\"a2a\""),
650            (ClaimSource::CodeSearch, "\"code_search\""),
651            (ClaimSource::Diagnostics, "\"diagnostics\""),
652            (ClaimSource::Memory, "\"memory\""),
653        ];
654        for (variant, expected_json) in cases {
655            let serialized = serde_json::to_string(&variant).unwrap();
656            assert_eq!(serialized, expected_json, "serialize {variant:?}");
657            let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
658            assert_eq!(deserialized, variant, "deserialize {variant:?}");
659        }
660    }
661
662    #[test]
663    fn audit_entry_claim_source_none_omitted() {
664        let entry = AuditEntry {
665            timestamp: "0".into(),
666            tool: "shell".into(),
667            command: "echo".into(),
668            result: AuditResult::Success,
669            duration_ms: 1,
670            error_category: None,
671            error_domain: None,
672            error_phase: None,
673            claim_source: None,
674            mcp_server_id: None,
675            injection_flagged: false,
676            embedding_anomalous: false,
677            cross_boundary_mcp_to_acp: false,
678            adversarial_policy_decision: None,
679            exit_code: None,
680            truncated: false,
681            policy_match: None,
682            correlation_id: None,
683            caller_id: None,
684            vigil_risk: None,
685            execution_env: None,
686            resolved_cwd: None,
687            scope_at_definition: None,
688            scope_at_dispatch: None,
689        };
690        let json = serde_json::to_string(&entry).unwrap();
691        assert!(
692            !json.contains("claim_source"),
693            "claim_source must be omitted when None: {json}"
694        );
695    }
696
697    #[test]
698    fn audit_entry_claim_source_some_present() {
699        use crate::executor::ClaimSource;
700        let entry = AuditEntry {
701            timestamp: "0".into(),
702            tool: "shell".into(),
703            command: "echo".into(),
704            result: AuditResult::Success,
705            duration_ms: 1,
706            error_category: None,
707            error_domain: None,
708            error_phase: None,
709            claim_source: Some(ClaimSource::Shell),
710            mcp_server_id: None,
711            injection_flagged: false,
712            embedding_anomalous: false,
713            cross_boundary_mcp_to_acp: false,
714            adversarial_policy_decision: None,
715            exit_code: None,
716            truncated: false,
717            policy_match: None,
718            correlation_id: None,
719            caller_id: None,
720            vigil_risk: None,
721            execution_env: None,
722            resolved_cwd: None,
723            scope_at_definition: None,
724            scope_at_dispatch: None,
725        };
726        let json = serde_json::to_string(&entry).unwrap();
727        assert!(
728            json.contains("\"claim_source\":\"shell\""),
729            "expected claim_source=shell in JSON: {json}"
730        );
731    }
732
733    #[tokio::test]
734    async fn audit_logger_multiple_entries() {
735        let dir = tempfile::tempdir().unwrap();
736        let path = dir.path().join("audit.log");
737        let config = AuditConfig {
738            enabled: true,
739            destination: path.display().to_string(),
740            ..Default::default()
741        };
742        let logger = AuditLogger::from_config(&config, false).await.unwrap();
743
744        for i in 0..5 {
745            let entry = AuditEntry {
746                timestamp: i.to_string(),
747                tool: "shell".into(),
748                command: format!("cmd{i}"),
749                result: AuditResult::Success,
750                duration_ms: i,
751                error_category: None,
752                error_domain: None,
753                error_phase: None,
754                claim_source: None,
755                mcp_server_id: None,
756                injection_flagged: false,
757                embedding_anomalous: false,
758                cross_boundary_mcp_to_acp: false,
759                adversarial_policy_decision: None,
760                exit_code: None,
761                truncated: false,
762                policy_match: None,
763                correlation_id: None,
764                caller_id: None,
765                vigil_risk: None,
766                execution_env: None,
767                resolved_cwd: None,
768                scope_at_definition: None,
769                scope_at_dispatch: None,
770            };
771            logger.log(&entry).await;
772        }
773
774        let content = tokio::fs::read_to_string(&path).await.unwrap();
775        assert_eq!(content.lines().count(), 5);
776    }
777
778    #[test]
779    fn audit_entry_exit_code_serialized() {
780        let entry = AuditEntry {
781            timestamp: "0".into(),
782            tool: "shell".into(),
783            command: "echo hi".into(),
784            result: AuditResult::Success,
785            duration_ms: 5,
786            error_category: None,
787            error_domain: None,
788            error_phase: None,
789            claim_source: None,
790            mcp_server_id: None,
791            injection_flagged: false,
792            embedding_anomalous: false,
793            cross_boundary_mcp_to_acp: false,
794            adversarial_policy_decision: None,
795            exit_code: Some(0),
796            truncated: false,
797            policy_match: None,
798            correlation_id: None,
799            caller_id: None,
800            vigil_risk: None,
801            execution_env: None,
802            resolved_cwd: None,
803            scope_at_definition: None,
804            scope_at_dispatch: None,
805        };
806        let json = serde_json::to_string(&entry).unwrap();
807        assert!(
808            json.contains("\"exit_code\":0"),
809            "exit_code must be serialized: {json}"
810        );
811    }
812
813    #[test]
814    fn audit_entry_exit_code_none_omitted() {
815        let entry = AuditEntry {
816            timestamp: "0".into(),
817            tool: "file".into(),
818            command: "read /tmp/x".into(),
819            result: AuditResult::Success,
820            duration_ms: 1,
821            error_category: None,
822            error_domain: None,
823            error_phase: None,
824            claim_source: None,
825            mcp_server_id: None,
826            injection_flagged: false,
827            embedding_anomalous: false,
828            cross_boundary_mcp_to_acp: false,
829            adversarial_policy_decision: None,
830            exit_code: None,
831            truncated: false,
832            policy_match: None,
833            correlation_id: None,
834            caller_id: None,
835            vigil_risk: None,
836            execution_env: None,
837            resolved_cwd: None,
838            scope_at_definition: None,
839            scope_at_dispatch: None,
840        };
841        let json = serde_json::to_string(&entry).unwrap();
842        assert!(
843            !json.contains("exit_code"),
844            "exit_code None must be omitted: {json}"
845        );
846    }
847
848    #[test]
849    fn log_tool_risk_summary_does_not_panic() {
850        log_tool_risk_summary(&[
851            "shell",
852            "bash",
853            "exec",
854            "web_scrape",
855            "fetch",
856            "scrape_page",
857            "file_write",
858            "file_read",
859            "file_delete",
860            "memory_search",
861            "unknown_tool",
862        ]);
863    }
864
865    #[test]
866    fn log_tool_risk_summary_empty_input_does_not_panic() {
867        log_tool_risk_summary(&[]);
868    }
869}