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