1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ToolAuditStatus {
38 Success,
40 Failure,
42 Timeout,
44 Cancelled,
46 Blocked,
48}
49
50impl ToolAuditStatus {
51 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ToolAuditEntry {
71 timestamp_unix_ms: u64,
73 session_id: String,
75 turn_id: String,
77 tool_call_id: String,
79 tool_name: String,
81 arguments_hash: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 arguments_redacted: Option<Value>,
86 result_hash: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 result_summary: Option<String>,
91 duration_ms: u64,
93 status: ToolAuditStatus,
95 #[serde(skip_serializing_if = "Option::is_none")]
98 sandbox_policy: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 transport: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 server_address: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 server_port: Option<u16>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 model_id: Option<String>,
111 prompt_injection_flagged: bool,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 reason: Option<String>,
117}
118
119pub trait ToolAuditSink: Send + Sync {
121 fn write(&self, entry: &ToolAuditEntry);
124
125 fn flush(&self) {}
127}
128
129#[derive(Debug, Default, Clone, Copy)]
131pub struct NullSink;
132
133impl ToolAuditSink for NullSink {
134 fn write(&self, _entry: &ToolAuditEntry) {}
135}
136
137#[derive(Debug, Default, Clone)]
139pub struct InMemorySink {
140 entries: Arc<Mutex<Vec<ToolAuditEntry>>>,
141}
142
143impl InMemorySink {
144 #[must_use]
146 fn new() -> Self {
147 Self::default()
148 }
149
150 fn entries(&self) -> Vec<ToolAuditEntry> {
152 self.entries.lock().expect("in-memory sink poisoned").clone()
153 }
154
155 fn len(&self) -> usize {
157 self.entries.lock().expect("in-memory sink poisoned").len()
158 }
159
160 fn is_empty(&self) -> bool {
162 self.len() == 0
163 }
164
165 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
177pub 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 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 #[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 if let Some(mut writer) = state.writer.take() {
242 drop(writer.flush());
243 }
244 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; 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
313pub 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 #[must_use]
332 fn new(sinks: Vec<Arc<dyn ToolAuditSink>>) -> Self {
333 Self { inner: sinks }
334 }
335
336 #[must_use]
338 pub fn len(&self) -> usize {
339 self.inner.len()
340 }
341
342 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#[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 #[must_use]
380 fn new(sink: Arc<dyn ToolAuditSink>) -> Self {
381 Self { sink }
382 }
383
384 #[must_use]
386 fn disabled() -> Self {
387 Self::new(Arc::new(NullSink))
388 }
389
390 fn record(&self, entry: ToolAuditEntry) {
392 self.sink.write(&entry);
393 }
394
395 fn flush(&self) {
397 self.sink.flush();
398 }
399
400 #[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#[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 }
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 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 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}