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        use zeph_config::AuditDestination as CfgDest;
282
283        let destination = match &config.destination {
284            CfgDest::Stdout if tui_mode => {
285                tracing::warn!("TUI mode: audit stdout redirected to file audit.jsonl");
286                let std_file = zeph_common::fs_secure::append_private(Path::new("audit.jsonl"))?;
287                let file = tokio::fs::File::from_std(std_file);
288                AuditDestination::File(tokio::sync::Mutex::new(file))
289            }
290            CfgDest::Stdout | CfgDest::Stderr => AuditDestination::Stdout,
291            CfgDest::File(path) => {
292                let std_file = zeph_common::fs_secure::append_private(path)?;
293                let file = tokio::fs::File::from_std(std_file);
294                AuditDestination::File(tokio::sync::Mutex::new(file))
295            }
296        };
297
298        Ok(Self { destination })
299    }
300
301    /// Serialize `entry` to JSON and append it to the configured destination.
302    ///
303    /// Serialization errors are logged via `tracing::error!` and silently swallowed so
304    /// that audit failures never interrupt tool execution.
305    pub async fn log(&self, entry: &AuditEntry) {
306        let json = match serde_json::to_string(entry) {
307            Ok(j) => j,
308            Err(err) => {
309                tracing::error!("audit entry serialization failed: {err}");
310                return;
311            }
312        };
313
314        match &self.destination {
315            AuditDestination::Stdout => {
316                tracing::info!(target: "audit", "{json}");
317            }
318            AuditDestination::File(file) => {
319                use tokio::io::AsyncWriteExt;
320                let mut f = file.lock().await;
321                let line = format!("{json}\n");
322                if let Err(e) = f.write_all(line.as_bytes()).await {
323                    tracing::error!("failed to write audit log: {e}");
324                } else if let Err(e) = f.flush().await {
325                    tracing::error!("failed to flush audit log: {e}");
326                }
327            }
328        }
329    }
330
331    /// Serialize an [`EgressEvent`] onto the same JSONL destination as [`AuditEntry`].
332    ///
333    /// Ordering with respect to [`AuditLogger::log`] is preserved by the shared
334    /// `tokio::sync::Mutex<File>` that serializes all writes on the same destination.
335    ///
336    /// Serialization errors are logged via `tracing::error!` and silently swallowed
337    /// so that egress logging failures never interrupt tool execution.
338    pub async fn log_egress(&self, event: &EgressEvent) {
339        let json = match serde_json::to_string(event) {
340            Ok(j) => j,
341            Err(err) => {
342                tracing::error!("egress event serialization failed: {err}");
343                return;
344            }
345        };
346
347        match &self.destination {
348            AuditDestination::Stdout => {
349                tracing::info!(target: "audit", "{json}");
350            }
351            AuditDestination::File(file) => {
352                use tokio::io::AsyncWriteExt;
353                let mut f = file.lock().await;
354                let line = format!("{json}\n");
355                if let Err(e) = f.write_all(line.as_bytes()).await {
356                    tracing::error!("failed to write egress log: {e}");
357                } else if let Err(e) = f.flush().await {
358                    tracing::error!("failed to flush egress log: {e}");
359                }
360            }
361        }
362    }
363}
364
365/// Log a per-tool risk summary at startup when `audit.tool_risk_summary = true`.
366///
367/// Each entry records tool name, privilege level (static mapping by tool id), and the
368/// expected input sanitization method. This is a design-time inventory label —
369/// NOT a runtime guarantee that sanitization is functioning correctly.
370pub fn log_tool_risk_summary(tool_ids: &[&str]) {
371    // Static privilege mapping: tool id prefix → (privilege level, expected sanitization).
372    // "high" = can execute arbitrary OS commands; "medium" = network/filesystem access;
373    // "low" = schema-validated parameters only.
374    fn classify(id: &str) -> (&'static str, &'static str) {
375        if id.starts_with("shell") || id == "bash" || id == "exec" {
376            ("high", "env_blocklist + command_blocklist")
377        } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
378            ("medium", "validate_url + SSRF + domain_policy")
379        } else if id.starts_with("file_write")
380            || id.starts_with("file_read")
381            || id.starts_with("file")
382        {
383            ("medium", "path_sandbox")
384        } else {
385            ("low", "schema_only")
386        }
387    }
388
389    for &id in tool_ids {
390        let (privilege, sanitization) = classify(id);
391        tracing::info!(
392            tool = id,
393            privilege_level = privilege,
394            expected_sanitization = sanitization,
395            "tool risk summary"
396        );
397    }
398}
399
400/// Returns the current Unix timestamp as a decimal string.
401///
402/// Used to populate [`AuditEntry::timestamp`]. Returns `"0"` if the system clock
403/// is before the Unix epoch (which should never happen in practice).
404#[must_use]
405pub fn chrono_now() -> String {
406    use std::time::{SystemTime, UNIX_EPOCH};
407    let secs = SystemTime::now()
408        .duration_since(UNIX_EPOCH)
409        .unwrap_or_default()
410        .as_secs();
411    format!("{secs}")
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn audit_entry_serialization() {
420        let entry = AuditEntry {
421            timestamp: "1234567890".into(),
422            tool: "shell".into(),
423            command: "echo hello".into(),
424            result: AuditResult::Success,
425            duration_ms: 42,
426            error_category: None,
427            error_domain: None,
428            error_phase: None,
429            claim_source: None,
430            mcp_server_id: None,
431            injection_flagged: false,
432            embedding_anomalous: false,
433            cross_boundary_mcp_to_acp: false,
434            adversarial_policy_decision: None,
435            exit_code: None,
436            truncated: false,
437            policy_match: None,
438            correlation_id: None,
439            caller_id: None,
440            vigil_risk: None,
441            execution_env: None,
442            resolved_cwd: None,
443            scope_at_definition: None,
444            scope_at_dispatch: None,
445        };
446        let json = serde_json::to_string(&entry).unwrap();
447        assert!(json.contains("\"type\":\"success\""));
448        assert!(json.contains("\"tool\":\"shell\""));
449        assert!(json.contains("\"duration_ms\":42"));
450    }
451
452    #[test]
453    fn audit_result_blocked_serialization() {
454        let entry = AuditEntry {
455            timestamp: "0".into(),
456            tool: "shell".into(),
457            command: "sudo rm".into(),
458            result: AuditResult::Blocked {
459                reason: "blocked command: sudo".into(),
460            },
461            duration_ms: 0,
462            error_category: Some("policy_blocked".to_owned()),
463            error_domain: Some("action".to_owned()),
464            error_phase: None,
465            claim_source: None,
466            mcp_server_id: None,
467            injection_flagged: false,
468            embedding_anomalous: false,
469            cross_boundary_mcp_to_acp: false,
470            adversarial_policy_decision: None,
471            exit_code: None,
472            truncated: false,
473            policy_match: None,
474            correlation_id: None,
475            caller_id: None,
476            vigil_risk: None,
477            execution_env: None,
478            resolved_cwd: None,
479            scope_at_definition: None,
480            scope_at_dispatch: None,
481        };
482        let json = serde_json::to_string(&entry).unwrap();
483        assert!(json.contains("\"type\":\"blocked\""));
484        assert!(json.contains("\"reason\""));
485    }
486
487    #[test]
488    fn audit_result_error_serialization() {
489        let entry = AuditEntry {
490            timestamp: "0".into(),
491            tool: "shell".into(),
492            command: "bad".into(),
493            result: AuditResult::Error {
494                message: "exec failed".into(),
495            },
496            duration_ms: 0,
497            error_category: None,
498            error_domain: None,
499            error_phase: None,
500            claim_source: None,
501            mcp_server_id: None,
502            injection_flagged: false,
503            embedding_anomalous: false,
504            cross_boundary_mcp_to_acp: false,
505            adversarial_policy_decision: None,
506            exit_code: None,
507            truncated: false,
508            policy_match: None,
509            correlation_id: None,
510            caller_id: None,
511            vigil_risk: None,
512            execution_env: None,
513            resolved_cwd: None,
514            scope_at_definition: None,
515            scope_at_dispatch: None,
516        };
517        let json = serde_json::to_string(&entry).unwrap();
518        assert!(json.contains("\"type\":\"error\""));
519    }
520
521    #[test]
522    fn audit_result_timeout_serialization() {
523        let entry = AuditEntry {
524            timestamp: "0".into(),
525            tool: "shell".into(),
526            command: "sleep 999".into(),
527            result: AuditResult::Timeout,
528            duration_ms: 30000,
529            error_category: Some("timeout".to_owned()),
530            error_domain: Some("system".to_owned()),
531            error_phase: None,
532            claim_source: None,
533            mcp_server_id: None,
534            injection_flagged: false,
535            embedding_anomalous: false,
536            cross_boundary_mcp_to_acp: false,
537            adversarial_policy_decision: None,
538            exit_code: None,
539            truncated: false,
540            policy_match: None,
541            correlation_id: None,
542            caller_id: None,
543            vigil_risk: None,
544            execution_env: None,
545            resolved_cwd: None,
546            scope_at_definition: None,
547            scope_at_dispatch: None,
548        };
549        let json = serde_json::to_string(&entry).unwrap();
550        assert!(json.contains("\"type\":\"timeout\""));
551    }
552
553    #[tokio::test]
554    async fn audit_logger_stdout() {
555        let config = AuditConfig {
556            enabled: true,
557            destination: crate::config::AuditDestination::Stdout,
558            ..Default::default()
559        };
560        let logger = AuditLogger::from_config(&config, false).await.unwrap();
561        let entry = AuditEntry {
562            timestamp: "0".into(),
563            tool: "shell".into(),
564            command: "echo test".into(),
565            result: AuditResult::Success,
566            duration_ms: 1,
567            error_category: None,
568            error_domain: None,
569            error_phase: None,
570            claim_source: None,
571            mcp_server_id: None,
572            injection_flagged: false,
573            embedding_anomalous: false,
574            cross_boundary_mcp_to_acp: false,
575            adversarial_policy_decision: None,
576            exit_code: None,
577            truncated: false,
578            policy_match: None,
579            correlation_id: None,
580            caller_id: None,
581            vigil_risk: None,
582            execution_env: None,
583            resolved_cwd: None,
584            scope_at_definition: None,
585            scope_at_dispatch: None,
586        };
587        logger.log(&entry).await;
588    }
589
590    #[tokio::test]
591    async fn audit_logger_file() {
592        let dir = tempfile::tempdir().unwrap();
593        let path = dir.path().join("audit.log");
594        let config = AuditConfig {
595            enabled: true,
596            destination: crate::config::AuditDestination::File(path.clone()),
597            ..Default::default()
598        };
599        let logger = AuditLogger::from_config(&config, false).await.unwrap();
600        let entry = AuditEntry {
601            timestamp: "0".into(),
602            tool: "shell".into(),
603            command: "echo test".into(),
604            result: AuditResult::Success,
605            duration_ms: 1,
606            error_category: None,
607            error_domain: None,
608            error_phase: None,
609            claim_source: None,
610            mcp_server_id: None,
611            injection_flagged: false,
612            embedding_anomalous: false,
613            cross_boundary_mcp_to_acp: false,
614            adversarial_policy_decision: None,
615            exit_code: None,
616            truncated: false,
617            policy_match: None,
618            correlation_id: None,
619            caller_id: None,
620            vigil_risk: None,
621            execution_env: None,
622            resolved_cwd: None,
623            scope_at_definition: None,
624            scope_at_dispatch: None,
625        };
626        logger.log(&entry).await;
627
628        let content = tokio::fs::read_to_string(&path).await.unwrap();
629        assert!(content.contains("\"tool\":\"shell\""));
630    }
631
632    #[tokio::test]
633    async fn audit_logger_file_write_error_logged() {
634        let config = AuditConfig {
635            enabled: true,
636            destination: crate::config::AuditDestination::File("/nonexistent/dir/audit.log".into()),
637            ..Default::default()
638        };
639        let result = AuditLogger::from_config(&config, false).await;
640        assert!(result.is_err());
641    }
642
643    #[test]
644    fn claim_source_serde_roundtrip() {
645        use crate::executor::ClaimSource;
646        let cases = [
647            (ClaimSource::Shell, "\"shell\""),
648            (ClaimSource::FileSystem, "\"file_system\""),
649            (ClaimSource::WebScrape, "\"web_scrape\""),
650            (ClaimSource::Mcp, "\"mcp\""),
651            (ClaimSource::A2a, "\"a2a\""),
652            (ClaimSource::CodeSearch, "\"code_search\""),
653            (ClaimSource::Diagnostics, "\"diagnostics\""),
654            (ClaimSource::Memory, "\"memory\""),
655        ];
656        for (variant, expected_json) in cases {
657            let serialized = serde_json::to_string(&variant).unwrap();
658            assert_eq!(serialized, expected_json, "serialize {variant:?}");
659            let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
660            assert_eq!(deserialized, variant, "deserialize {variant:?}");
661        }
662    }
663
664    #[test]
665    fn audit_entry_claim_source_none_omitted() {
666        let entry = AuditEntry {
667            timestamp: "0".into(),
668            tool: "shell".into(),
669            command: "echo".into(),
670            result: AuditResult::Success,
671            duration_ms: 1,
672            error_category: None,
673            error_domain: None,
674            error_phase: None,
675            claim_source: None,
676            mcp_server_id: None,
677            injection_flagged: false,
678            embedding_anomalous: false,
679            cross_boundary_mcp_to_acp: false,
680            adversarial_policy_decision: None,
681            exit_code: None,
682            truncated: false,
683            policy_match: None,
684            correlation_id: None,
685            caller_id: None,
686            vigil_risk: None,
687            execution_env: None,
688            resolved_cwd: None,
689            scope_at_definition: None,
690            scope_at_dispatch: None,
691        };
692        let json = serde_json::to_string(&entry).unwrap();
693        assert!(
694            !json.contains("claim_source"),
695            "claim_source must be omitted when None: {json}"
696        );
697    }
698
699    #[test]
700    fn audit_entry_claim_source_some_present() {
701        use crate::executor::ClaimSource;
702        let entry = AuditEntry {
703            timestamp: "0".into(),
704            tool: "shell".into(),
705            command: "echo".into(),
706            result: AuditResult::Success,
707            duration_ms: 1,
708            error_category: None,
709            error_domain: None,
710            error_phase: None,
711            claim_source: Some(ClaimSource::Shell),
712            mcp_server_id: None,
713            injection_flagged: false,
714            embedding_anomalous: false,
715            cross_boundary_mcp_to_acp: false,
716            adversarial_policy_decision: None,
717            exit_code: None,
718            truncated: false,
719            policy_match: None,
720            correlation_id: None,
721            caller_id: None,
722            vigil_risk: None,
723            execution_env: None,
724            resolved_cwd: None,
725            scope_at_definition: None,
726            scope_at_dispatch: None,
727        };
728        let json = serde_json::to_string(&entry).unwrap();
729        assert!(
730            json.contains("\"claim_source\":\"shell\""),
731            "expected claim_source=shell in JSON: {json}"
732        );
733    }
734
735    #[tokio::test]
736    async fn audit_logger_multiple_entries() {
737        let dir = tempfile::tempdir().unwrap();
738        let path = dir.path().join("audit.log");
739        let config = AuditConfig {
740            enabled: true,
741            destination: crate::config::AuditDestination::File(path.clone()),
742            ..Default::default()
743        };
744        let logger = AuditLogger::from_config(&config, false).await.unwrap();
745
746        for i in 0..5 {
747            let entry = AuditEntry {
748                timestamp: i.to_string(),
749                tool: "shell".into(),
750                command: format!("cmd{i}"),
751                result: AuditResult::Success,
752                duration_ms: i,
753                error_category: None,
754                error_domain: None,
755                error_phase: None,
756                claim_source: None,
757                mcp_server_id: None,
758                injection_flagged: false,
759                embedding_anomalous: false,
760                cross_boundary_mcp_to_acp: false,
761                adversarial_policy_decision: None,
762                exit_code: None,
763                truncated: false,
764                policy_match: None,
765                correlation_id: None,
766                caller_id: None,
767                vigil_risk: None,
768                execution_env: None,
769                resolved_cwd: None,
770                scope_at_definition: None,
771                scope_at_dispatch: None,
772            };
773            logger.log(&entry).await;
774        }
775
776        let content = tokio::fs::read_to_string(&path).await.unwrap();
777        assert_eq!(content.lines().count(), 5);
778    }
779
780    #[test]
781    fn audit_entry_exit_code_serialized() {
782        let entry = AuditEntry {
783            timestamp: "0".into(),
784            tool: "shell".into(),
785            command: "echo hi".into(),
786            result: AuditResult::Success,
787            duration_ms: 5,
788            error_category: None,
789            error_domain: None,
790            error_phase: None,
791            claim_source: None,
792            mcp_server_id: None,
793            injection_flagged: false,
794            embedding_anomalous: false,
795            cross_boundary_mcp_to_acp: false,
796            adversarial_policy_decision: None,
797            exit_code: Some(0),
798            truncated: false,
799            policy_match: None,
800            correlation_id: None,
801            caller_id: None,
802            vigil_risk: None,
803            execution_env: None,
804            resolved_cwd: None,
805            scope_at_definition: None,
806            scope_at_dispatch: None,
807        };
808        let json = serde_json::to_string(&entry).unwrap();
809        assert!(
810            json.contains("\"exit_code\":0"),
811            "exit_code must be serialized: {json}"
812        );
813    }
814
815    #[test]
816    fn audit_entry_exit_code_none_omitted() {
817        let entry = AuditEntry {
818            timestamp: "0".into(),
819            tool: "file".into(),
820            command: "read /tmp/x".into(),
821            result: AuditResult::Success,
822            duration_ms: 1,
823            error_category: None,
824            error_domain: None,
825            error_phase: None,
826            claim_source: None,
827            mcp_server_id: None,
828            injection_flagged: false,
829            embedding_anomalous: false,
830            cross_boundary_mcp_to_acp: false,
831            adversarial_policy_decision: None,
832            exit_code: None,
833            truncated: false,
834            policy_match: None,
835            correlation_id: None,
836            caller_id: None,
837            vigil_risk: None,
838            execution_env: None,
839            resolved_cwd: None,
840            scope_at_definition: None,
841            scope_at_dispatch: None,
842        };
843        let json = serde_json::to_string(&entry).unwrap();
844        assert!(
845            !json.contains("exit_code"),
846            "exit_code None must be omitted: {json}"
847        );
848    }
849
850    #[test]
851    fn log_tool_risk_summary_does_not_panic() {
852        log_tool_risk_summary(&[
853            "shell",
854            "bash",
855            "exec",
856            "web_scrape",
857            "fetch",
858            "scrape_page",
859            "file_write",
860            "file_read",
861            "file_delete",
862            "memory_search",
863            "unknown_tool",
864        ]);
865    }
866
867    #[test]
868    fn log_tool_risk_summary_empty_input_does_not_panic() {
869        log_tool_risk_summary(&[]);
870    }
871}