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