Skip to main content

vtcode_safety/
audit_log.rs

1//! Persistent audit logging for tool invocations.
2//!
3//! §18.4.4 of *The Hitchhiker's Guide to Agentic AI* calls for an audit log of
4//! every tool call (arguments, outputs, timestamp). The existing
5//! [`crate::command_safety::audit::SafetyAuditLogger`] only records command-safety
6//! decisions in memory; this module adds a complementary, opt-in sink that
7//! records every MCP and built-in tool invocation in a durable, append-only
8//! JSONL stream.
9//!
10//! ## Sinks
11//!
12//! - [`JsonlFileSink`] — appends entries to a JSONL file with size-based rotation.
13//! - [`InMemorySink`] — keeps entries in memory for tests and short-lived tooling.
14//! - [`NullSink`] — discards everything; zero-cost when audit is disabled.
15//! - [`MultiSink`] — fan-out wrapper used by [`ToolAuditLogger`].
16//!
17//! ## Threading
18//!
19//! Sinks are `Send + Sync`. Writes are non-blocking from the caller's perspective:
20//! each sink uses an internal lock or background task to serialize I/O.
21//!
22//! See also `crates/codegen/vtcode-core/src/tools/untrusted_data.rs` for the prompt-injection
23//! defense that pairs with this log.
24
25use std::fs::File;
26use std::io::{BufWriter, Write};
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, Mutex};
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32use vtcode_commons::VtCodePaths;
33
34/// Status of a single tool invocation for audit purposes.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ToolAuditStatus {
38    /// Tool ran to completion and produced a result.
39    Success,
40    /// Tool returned an error before completing.
41    Failure,
42    /// Tool exceeded its wall-clock budget.
43    Timeout,
44    /// Tool execution was cancelled (HITL refusal, planning denial, etc.).
45    Cancelled,
46    /// Tool execution was blocked before it started (e.g. loop detector, fuse).
47    Blocked,
48}
49
50impl ToolAuditStatus {
51    /// Short string label suitable for log lines.
52    #[must_use]
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::Success => "success",
56            Self::Failure => "failure",
57            Self::Timeout => "timeout",
58            Self::Cancelled => "cancelled",
59            Self::Blocked => "blocked",
60        }
61    }
62}
63
64/// One audit row written per tool invocation.
65///
66/// The shape mirrors what `tracing` already records for MCP tool calls
67/// (`mcp.tools.call` span with provider / transport / server metadata) but adds
68/// enough context for offline forensics.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ToolAuditEntry {
71    /// Unix epoch milliseconds when the entry was created.
72    timestamp_unix_ms: u64,
73    /// Stable identifier for the session (e.g. UUID).
74    session_id: String,
75    /// Stable identifier for the turn within the session.
76    turn_id: String,
77    /// Provider-side identifier for the tool call (matches `tool_call_id`).
78    tool_call_id: String,
79    /// Canonical tool name (e.g. `mcp::fetch::fetch`, `read_file`).
80    tool_name: String,
81    /// SHA-256 of the original (unredacted) tool arguments, hex-encoded.
82    arguments_hash: String,
83    /// Optional redacted snapshot of the arguments (secrets removed).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    arguments_redacted: Option<Value>,
86    /// SHA-256 of the tool result, hex-encoded.
87    result_hash: String,
88    /// Optional first N characters of the result for offline triage.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    result_summary: Option<String>,
91    /// Wall-clock duration of the tool call in milliseconds.
92    duration_ms: u64,
93    /// Final status (success / failure / timeout / cancelled / blocked).
94    status: ToolAuditStatus,
95    /// Optional sandbox policy applied to this tool call (path to a JSON
96    /// snapshot, or a short identifier).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    sandbox_policy: Option<String>,
99    /// MCP transport when applicable (`stdio`, `streamable_http`, …).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    transport: Option<String>,
102    /// Remote server address when applicable.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    server_address: Option<String>,
105    /// Remote server port when applicable.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    server_port: Option<u16>,
108    /// Identifier of the model that requested the call.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    model_id: Option<String>,
111    /// True when the static `is_suspicious_instruction` probe flagged the
112    /// result content.
113    prompt_injection_flagged: bool,
114    /// Optional free-form reason (e.g. `loop_detector`, `circuit_breaker_open`).
115    #[serde(skip_serializing_if = "Option::is_none")]
116    reason: Option<String>,
117}
118
119/// Trait every audit sink implements.
120pub trait ToolAuditSink: Send + Sync {
121    /// Persist a single entry. Implementations must never block on network I/O
122    /// (disk I/O is fine — JSONL writes are O(milliseconds) and buffered).
123    fn write(&self, entry: &ToolAuditEntry);
124
125    /// Flush any buffered entries to durable storage. Default no-op.
126    fn flush(&self) {}
127}
128
129/// Null sink — accepts every entry but discards them.
130#[derive(Debug, Default, Clone, Copy)]
131pub struct NullSink;
132
133impl ToolAuditSink for NullSink {
134    fn write(&self, _entry: &ToolAuditEntry) {}
135}
136
137/// In-memory sink — useful for tests and ephemeral tooling.
138#[derive(Debug, Default, Clone)]
139pub struct InMemorySink {
140    entries: Arc<Mutex<Vec<ToolAuditEntry>>>,
141}
142
143impl InMemorySink {
144    /// Create a new in-memory sink with no entries.
145    #[must_use]
146    fn new() -> Self {
147        Self::default()
148    }
149
150    /// Snapshot all entries recorded so far.
151    fn entries(&self) -> Vec<ToolAuditEntry> {
152        self.entries.lock().expect("in-memory sink poisoned").clone()
153    }
154
155    /// Number of recorded entries.
156    fn len(&self) -> usize {
157        self.entries.lock().expect("in-memory sink poisoned").len()
158    }
159
160    /// Returns true when no entries have been recorded.
161    fn is_empty(&self) -> bool {
162        self.len() == 0
163    }
164
165    /// Drop all recorded entries.
166    fn clear(&self) {
167        self.entries.lock().expect("in-memory sink poisoned").clear();
168    }
169}
170
171impl ToolAuditSink for InMemorySink {
172    fn write(&self, entry: &ToolAuditEntry) {
173        self.entries.lock().expect("in-memory sink poisoned").push(entry.clone());
174    }
175}
176
177/// Append-only JSONL file sink with size-based rotation.
178///
179/// The sink writes each entry as a single JSON line. When the current file
180/// exceeds `max_size_bytes`, it is renamed to `<path>.1` (and previous
181/// generations shift up to `<path>.N`) — keeping at most `max_files` files
182/// on disk.
183pub struct JsonlFileSink {
184    path: PathBuf,
185    max_size_bytes: u64,
186    max_files: usize,
187    state: Mutex<JsonlFileState>,
188}
189
190struct JsonlFileState {
191    writer: Option<BufWriter<File>>,
192    bytes_written: u64,
193}
194
195impl std::fmt::Debug for JsonlFileSink {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        f.debug_struct("JsonlFileSink")
198            .field("path", &self.path)
199            .field("max_size_bytes", &self.max_size_bytes)
200            .field("max_files", &self.max_files)
201            .finish_non_exhaustive()
202    }
203}
204
205impl JsonlFileSink {
206    /// Open (or create) the sink at `path`. Returns an error if the file can't
207    /// be created — typically a permissions issue.
208    fn open(path: impl Into<PathBuf>, max_size_bytes: u64, max_files: usize) -> std::io::Result<Self> {
209        let path = path.into();
210        if let Some(parent) = path.parent() {
211            VtCodePaths::ensure_user_dir(parent)
212                .map_err(std::io::Error::other)
213                .map(|_| ())?;
214        }
215        let file = VtCodePaths::open_private_append_file(&path).map_err(std::io::Error::other)?;
216        let bytes_written = file.metadata().map(|m| m.len()).unwrap_or(0);
217        Ok(Self {
218            path,
219            max_size_bytes,
220            max_files: max_files.max(1),
221            state: Mutex::new(JsonlFileState { writer: Some(BufWriter::new(file)), bytes_written }),
222        })
223    }
224
225    /// Path of the active JSONL file.
226    #[must_use]
227    pub fn path(&self) -> &Path {
228        &self.path
229    }
230
231    fn rotate_if_needed(
232        state: &mut JsonlFileState,
233        path: &Path,
234        max_size_bytes: u64,
235        max_files: usize,
236    ) -> std::io::Result<()> {
237        if state.bytes_written < max_size_bytes {
238            return Ok(());
239        }
240        // Drop the writer before renaming.
241        if let Some(mut writer) = state.writer.take() {
242            drop(writer.flush());
243        }
244        // Shift generations: <path>.N-1 -> <path>.N, …, <path> -> <path>.1
245        for index in (1..max_files).rev() {
246            let from = rotated_path(path, index);
247            let to = rotated_path(path, index + 1);
248            if from.exists() {
249                drop(std::fs::rename(&from, &to));
250            }
251        }
252        if path.exists() {
253            std::fs::rename(path, rotated_path(path, 1))?;
254        }
255        let file = VtCodePaths::open_private_append_file(path).map_err(std::io::Error::other)?;
256        state.writer = Some(BufWriter::new(file));
257        state.bytes_written = 0;
258        Ok(())
259    }
260}
261
262fn rotated_path(path: &Path, index: usize) -> PathBuf {
263    let mut s = path.as_os_str().to_owned();
264    s.push(format!(".{index}"));
265    PathBuf::from(s)
266}
267
268impl ToolAuditSink for JsonlFileSink {
269    fn write(&self, entry: &ToolAuditEntry) {
270        let mut state = match self.state.lock() {
271            Ok(state) => state,
272            Err(poisoned) => poisoned.into_inner(),
273        };
274
275        let serialized = match serde_json::to_string(entry) {
276            Ok(serialized) => serialized,
277            Err(err) => {
278                tracing::warn!(error = %err, "JsonlFileSink: failed to serialize audit entry");
279                return;
280            }
281        };
282        let line_length = serialized.len() as u64 + 1; // account for trailing newline
283
284        if let Err(err) = Self::rotate_if_needed(&mut state, &self.path, self.max_size_bytes, self.max_files) {
285            tracing::warn!(error = %err, path = %self.path.display(), "JsonlFileSink: rotation failed");
286        }
287        if let Some(writer) = state.writer.as_mut() {
288            if let Err(err) = writeln!(writer, "{serialized}") {
289                tracing::warn!(error = %err, path = %self.path.display(), "JsonlFileSink: write failed");
290                return;
291            }
292            state.bytes_written = state.bytes_written.saturating_add(line_length);
293        }
294    }
295
296    fn flush(&self) {
297        let mut state = match self.state.lock() {
298            Ok(state) => state,
299            Err(poisoned) => poisoned.into_inner(),
300        };
301        if let Some(writer) = state.writer.as_mut() {
302            drop(writer.flush());
303        }
304    }
305}
306
307impl Drop for JsonlFileSink {
308    fn drop(&mut self) {
309        self.flush();
310    }
311}
312
313/// Fan-out sink — forwards every entry to a list of inner sinks.
314///
315/// Sinks are called sequentially in registration order; a panic in one sink
316/// (theoretically impossible, but defensive) doesn't suppress the others.
317pub struct MultiSink {
318    inner: Vec<Arc<dyn ToolAuditSink>>,
319}
320
321impl std::fmt::Debug for MultiSink {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.debug_struct("MultiSink").field("sink_count", &self.inner.len()).finish()
324    }
325}
326
327impl MultiSink {
328    /// Create a fan-out sink. The slice is moved into `Arc`s to allow sharing
329    /// with callers that want to read from one of the sinks while still
330    /// forwarding writes.
331    #[must_use]
332    fn new(sinks: Vec<Arc<dyn ToolAuditSink>>) -> Self {
333        Self { inner: sinks }
334    }
335
336    /// Number of inner sinks.
337    #[must_use]
338    pub fn len(&self) -> usize {
339        self.inner.len()
340    }
341
342    /// Returns true when no inner sinks are registered.
343    pub fn is_empty(&self) -> bool {
344        self.inner.is_empty()
345    }
346}
347
348impl ToolAuditSink for MultiSink {
349    fn write(&self, entry: &ToolAuditEntry) {
350        for sink in &self.inner {
351            sink.write(entry);
352        }
353    }
354
355    fn flush(&self) {
356        for sink in &self.inner {
357            sink.flush();
358        }
359    }
360}
361
362/// Top-level audit logger — currently a thin wrapper that owns a single sink.
363///
364/// Future iterations can grow this into a fan-out by default (e.g. always
365/// pair [`InMemorySink`] with the user's configured persistent sink).
366#[derive(Clone)]
367pub struct ToolAuditLogger {
368    sink: Arc<dyn ToolAuditSink>,
369}
370
371impl std::fmt::Debug for ToolAuditLogger {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        f.debug_struct("ToolAuditLogger").finish_non_exhaustive()
374    }
375}
376
377impl ToolAuditLogger {
378    /// Wrap a sink in a logger.
379    #[must_use]
380    fn new(sink: Arc<dyn ToolAuditSink>) -> Self {
381        Self { sink }
382    }
383
384    /// Disabled logger that drops every entry (equivalent to `NullSink`).
385    #[must_use]
386    fn disabled() -> Self {
387        Self::new(Arc::new(NullSink))
388    }
389
390    /// Persist a single entry through the underlying sink.
391    fn record(&self, entry: ToolAuditEntry) {
392        self.sink.write(&entry);
393    }
394
395    /// Flush the underlying sink.
396    fn flush(&self) {
397        self.sink.flush();
398    }
399
400    /// Borrow the inner sink (used by tests and the CLI debug commands).
401    #[must_use]
402    pub fn sink(&self) -> &Arc<dyn ToolAuditSink> {
403        &self.sink
404    }
405}
406
407impl Default for ToolAuditLogger {
408    fn default() -> Self {
409        Self::disabled()
410    }
411}
412
413/// Helper: SHA-256 hash of arbitrary bytes, hex-encoded.
414///
415/// Exposed so callers building a `ToolAuditEntry` can compute `arguments_hash`
416/// / `result_hash` without pulling in `sha2` directly.
417#[must_use]
418fn sha256_hex(bytes: &[u8]) -> String {
419    use sha2::{Digest, Sha256};
420    let mut hasher = Sha256::new();
421    hasher.update(bytes);
422    let digest = hasher.finalize();
423    let mut out = String::with_capacity(digest.len() * 2);
424    for byte in digest {
425        use std::fmt::Write;
426        let _ignored = write!(&mut out, "{byte:02x}");
427    }
428    out
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use tempfile::TempDir;
435
436    fn sample_entry(suffix: &str) -> ToolAuditEntry {
437        ToolAuditEntry {
438            timestamp_unix_ms: 1_700_000_000_000 + u64::from(suffix.bytes().next().unwrap_or(b'a')),
439            session_id: format!("session-{suffix}"),
440            turn_id: format!("turn-{suffix}"),
441            tool_call_id: format!("call-{suffix}"),
442            tool_name: "mcp::fetch::fetch".to_owned(),
443            arguments_hash: sha256_hex(suffix.as_bytes()),
444            arguments_redacted: None,
445            result_hash: sha256_hex(format!("result-{suffix}").as_bytes()),
446            result_summary: Some(format!("first line of result {suffix}")),
447            duration_ms: 42,
448            status: ToolAuditStatus::Success,
449            sandbox_policy: None,
450            transport: Some("stdio".to_owned()),
451            server_address: None,
452            server_port: None,
453            model_id: Some("test-model".to_owned()),
454            prompt_injection_flagged: false,
455            reason: None,
456        }
457    }
458
459    #[test]
460    fn in_memory_sink_records_entries() {
461        let sink = InMemorySink::new();
462        sink.write(&sample_entry("a"));
463        sink.write(&sample_entry("b"));
464        assert_eq!(sink.len(), 2);
465        assert_eq!(sink.entries()[0].tool_name, "mcp::fetch::fetch");
466        sink.clear();
467        assert!(sink.is_empty());
468    }
469
470    #[test]
471    fn null_sink_accepts_without_recording() {
472        let sink = NullSink;
473        sink.write(&sample_entry("x"));
474        // No observable state, but the call must not panic.
475    }
476
477    #[test]
478    fn jsonl_file_sink_appends_and_flushes_on_drop() {
479        let dir = TempDir::new().expect("tempdir");
480        let path = dir.path().join("audit.jsonl");
481        let sink = JsonlFileSink::open(&path, 1024 * 1024, 4).expect("open sink");
482
483        sink.write(&sample_entry("a"));
484        sink.write(&sample_entry("b"));
485        sink.flush();
486
487        let body = std::fs::read_to_string(&path).expect("read back");
488        let lines: Vec<&str> = body.lines().collect();
489        assert_eq!(lines.len(), 2);
490        for line in lines {
491            let value: Value = serde_json::from_str(line).expect("line is valid JSON");
492            assert_eq!(value["tool_name"], "mcp::fetch::fetch");
493        }
494    }
495
496    #[test]
497    fn jsonl_file_sink_rotates_when_threshold_exceeded() {
498        let dir = TempDir::new().expect("tempdir");
499        let path = dir.path().join("audit.jsonl");
500        // Tiny rotation threshold so the second entry triggers a rotate.
501        let sink = JsonlFileSink::open(&path, 60, 3).expect("open sink");
502
503        sink.write(&sample_entry("a"));
504        sink.flush();
505        sink.write(&sample_entry("b"));
506        sink.flush();
507
508        // After rotation, the original path should contain the most recent entry
509        // and `<path>.1` should contain the older one.
510        let active = std::fs::read_to_string(&path).expect("active");
511        assert!(active.contains("\"call-b\""), "expected rotated active file to contain call-b, got: {active}");
512        let rotated = std::fs::read_to_string(dir.path().join("audit.jsonl.1")).expect("rotated");
513        assert!(rotated.contains("\"call-a\""), "expected rotated file to contain call-a, got: {rotated}");
514    }
515
516    #[test]
517    fn multi_sink_forwards_to_every_inner_sink() {
518        let a = Arc::new(InMemorySink::new());
519        let b = Arc::new(InMemorySink::new());
520        let multi = MultiSink::new(vec![a.clone(), b.clone()]);
521        multi.write(&sample_entry("z"));
522        assert_eq!(a.len(), 1);
523        assert_eq!(b.len(), 1);
524    }
525
526    #[test]
527    fn tool_audit_logger_record_routes_through_sink() {
528        let sink = Arc::new(InMemorySink::new());
529        let logger = ToolAuditLogger::new(sink.clone());
530        logger.record(sample_entry("k"));
531        logger.flush();
532        assert_eq!(sink.len(), 1);
533    }
534
535    #[test]
536    fn sha256_hex_is_stable() {
537        assert_eq!(sha256_hex(b"hello"), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
538        assert_eq!(sha256_hex(b"hello"), sha256_hex(b"hello"));
539    }
540}