use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{broadcast, mpsc};
use crate::audit::record::AuditRecord;
const AUDIT_CHANNEL_DEPTH: usize = 1024;
const STREAM_BROADCAST_DEPTH: usize = 256;
pub(crate) fn append_record(dir: &Path, rec: &AuditRecord) -> std::io::Result<()> {
use std::io::Write as _;
let month = rec.ts.get(0..7).unwrap_or("0000-00");
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{month}.jsonl"));
let mut line = serde_json::to_vec(rec)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push(b'\n');
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
f.write_all(&line)
}
pub use mcpmesh_local_api::ActiveSession;
pub struct AuditLog {
tx: mpsc::Sender<AuditRecord>,
dir: PathBuf,
bcast: broadcast::Sender<AuditRecord>,
live: Mutex<HashMap<u64, ActiveSession>>,
seq: AtomicU64,
}
impl AuditLog {
pub fn spawn(dir: PathBuf) -> Arc<Self> {
let writer_dir = dir.clone();
let (tx, mut rx) = mpsc::channel::<AuditRecord>(AUDIT_CHANNEL_DEPTH);
let (bcast, _) = broadcast::channel(STREAM_BROADCAST_DEPTH);
tokio::spawn(async move {
while let Some(rec) = rx.recv().await {
let dir = writer_dir.clone();
let res = tokio::task::spawn_blocking(move || append_record(&dir, &rec)).await;
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(%e, "audit append failed (record dropped)"),
Err(e) => tracing::warn!(%e, "audit writer join failed (record dropped)"),
}
}
});
Arc::new(Self {
tx,
dir,
bcast,
live: Mutex::new(HashMap::new()),
seq: AtomicU64::new(0),
})
}
pub fn record(&self, rec: AuditRecord) {
let _ = self.bcast.send(rec.clone());
if let Err(e) = self.tx.try_send(rec) {
tracing::debug!(%e, "audit channel full or closed; dropping record (best-effort)");
}
}
pub fn subscribe(&self) -> broadcast::Receiver<AuditRecord> {
self.bcast.subscribe()
}
pub fn active_sessions(&self) -> Vec<ActiveSession> {
let mut v: Vec<ActiveSession> = self
.live
.lock()
.expect("audit live lock")
.values()
.cloned()
.collect();
v.sort_by_key(|s| s.opened_at);
v
}
fn open_tracked(&self, peer: String, service: String) -> u64 {
let id = self.seq.fetch_add(1, Ordering::Relaxed);
self.record(AuditRecord::session_open(
now_ts(),
Some(peer.clone()),
service.clone(),
));
self.live.lock().expect("audit live lock").insert(
id,
ActiveSession {
peer,
service,
opened_at: crate::util::epoch_now_i64(),
},
);
id
}
fn close_tracked(&self, id: u64) {
let removed = self.live.lock().expect("audit live lock").remove(&id);
if let Some(s) = removed {
self.record(AuditRecord::session_close(
now_ts(),
Some(s.peer),
s.service,
));
}
}
}
#[derive(Clone, Default)]
pub struct AuditSink(Option<Arc<AuditLog>>);
impl AuditSink {
pub fn new(log: Arc<AuditLog>) -> Self {
Self(Some(log))
}
pub fn disabled() -> Self {
Self(None)
}
pub fn dir(&self) -> Option<&Path> {
self.0.as_ref().map(|log| log.dir.as_path())
}
pub fn record(&self, rec: AuditRecord) {
if let Some(log) = &self.0 {
log.record(rec);
}
}
pub fn session(&self, peer: String, service: String) -> SessionGuard {
match &self.0 {
Some(log) => SessionGuard {
id: log.open_tracked(peer, service),
log: Some(log.clone()),
},
None => SessionGuard { log: None, id: 0 },
}
}
pub fn subscribe(&self) -> Option<broadcast::Receiver<AuditRecord>> {
self.0.as_ref().map(|log| log.subscribe())
}
pub fn active_sessions(&self) -> Vec<ActiveSession> {
self.0
.as_ref()
.map(|log| log.active_sessions())
.unwrap_or_default()
}
}
pub struct SessionGuard {
log: Option<Arc<AuditLog>>,
id: u64,
}
impl Drop for SessionGuard {
fn drop(&mut self) {
if let Some(log) = &self.log {
log.close_tracked(self.id);
}
}
}
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
use serde_json::Value;
use crate::audit::record::{args_hash, now_ts};
struct Pending {
method: String,
tool: Option<String>,
args_hash: String,
started: Instant,
}
#[derive(Clone)]
pub struct RequestAuditor {
inner: Option<Arc<RequestAuditorInner>>,
}
struct RequestAuditorInner {
sink: AuditSink,
peer: Option<String>,
service: String,
pending: Mutex<HashMap<String, Pending>>,
}
impl RequestAuditor {
pub fn new(sink: AuditSink, peer: Option<String>, service: String) -> Self {
Self {
inner: Some(Arc::new(RequestAuditorInner {
sink,
peer,
service,
pending: Mutex::new(HashMap::new()),
})),
}
}
pub fn on_request(&self, frame: &Value) {
let Some(inner) = &self.inner else { return };
let Some(method) = frame.get("method").and_then(Value::as_str) else {
return; };
let tool = if method == "tools/call" {
frame
.pointer("/params/name")
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
};
let ah = args_hash(frame.get("params").unwrap_or(&Value::Null));
match frame.get("id") {
Some(id) if !id.is_null() => {
let key = id.to_string();
let mut pending = inner
.pending
.lock()
.expect("audit pending map not poisoned");
pending.insert(
key,
Pending {
method: method.to_string(),
tool,
args_hash: ah,
started: Instant::now(),
},
);
}
_ => {
inner.sink.record(AuditRecord::proxied_notification(
now_ts(),
inner.peer.clone(),
inner.service.clone(),
method.to_string(),
tool,
ah,
));
}
}
}
pub fn on_response(&self, frame: &Value, bytes_out: u64) {
let Some(inner) = &self.inner else { return };
if frame.get("method").is_some() {
return;
}
let Some(id) = frame.get("id").filter(|v| !v.is_null()) else {
return; };
let key = id.to_string();
let pending = {
let mut map = inner
.pending
.lock()
.expect("audit pending map not poisoned");
map.remove(&key)
};
let Some(p) = pending else { return };
let status = if frame.get("error").is_some() {
"error"
} else {
"ok"
};
let latency_ms = p.started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
inner.sink.record(AuditRecord::proxied_request(
now_ts(),
inner.peer.clone(),
inner.service.clone(),
p.method,
p.tool,
p.args_hash,
bytes_out,
status.to_string(),
latency_ms,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::record::{AuditRecord, args_hash};
use serde_json::json;
#[test]
fn append_writes_jsonl_to_the_month_file_and_hides_raw_args() {
let dir = tempfile::tempdir().unwrap();
let secret = "top-secret-argument-value";
let params = json!({"arguments": {"q": secret}});
let rec = AuditRecord::proxied_request(
"2026-07-03T14:02:11.480Z".into(),
Some("bob".into()),
"notes".into(),
"tools/call".into(),
Some("read_file".into()),
args_hash(¶ms),
42,
"ok".into(),
7,
);
append_record(dir.path(), &rec).unwrap();
let file = dir.path().join("2026-07.jsonl");
let body = std::fs::read_to_string(&file).unwrap();
assert!(body.ends_with('\n'), "one JSONL line, newline-terminated");
assert_eq!(body.lines().count(), 1);
assert!(
!body.contains(secret),
"raw argument leaked to disk: {body}"
);
assert!(body.contains("blake3:"));
let rec2 =
AuditRecord::session_open("2026-08-01T00:00:00.000Z".into(), None, "notes".into());
append_record(dir.path(), &rec2).unwrap();
assert!(dir.path().join("2026-08.jsonl").exists());
assert_eq!(std::fs::read_to_string(&file).unwrap().lines().count(), 1);
}
#[tokio::test]
async fn record_is_non_blocking_and_writer_persists() {
let dir = tempfile::tempdir().unwrap();
let log = AuditLog::spawn(dir.path().to_path_buf());
let sink = AuditSink::new(log);
for i in 0..5 {
sink.record(AuditRecord::session_open(
format!("2026-07-03T14:02:1{i}.000Z"),
Some("bob".into()),
"notes".into(),
));
}
let file = dir.path().join("2026-07.jsonl");
let mut lines = 0;
for _ in 0..50 {
if let Ok(body) = std::fs::read_to_string(&file) {
lines = body.lines().count();
if lines >= 5 {
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert_eq!(lines, 5, "all five records persisted");
}
#[test]
fn disabled_sink_is_a_silent_no_op() {
let sink = AuditSink::disabled();
sink.record(AuditRecord::trust(
"2026-07-03T14:02:11.480Z".into(),
"pair".into(),
None,
));
}
#[tokio::test]
async fn request_auditor_records_correlated_line_without_raw_args() {
let dir = tempfile::tempdir().unwrap();
let audit_dir = dir.path().to_path_buf();
let sink = AuditSink::new(AuditLog::spawn(audit_dir.clone()));
let auditor = RequestAuditor::new(sink, Some("bob".into()), "notes".into());
let secret = "sensitive-search-query-xyzzy";
let req = json!({
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "read_file", "arguments": {"query": secret}}
});
auditor.on_request(&req);
auditor.on_request(&json!({
"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"token": "t1"}
}));
let resp = json!({"jsonrpc": "2.0", "id": 7, "result": {"content": []}});
auditor.on_response(&resp, 6210);
let month = &crate::audit::now_ts()[..7];
let file = audit_dir.join(format!("{month}.jsonl"));
let mut body = String::new();
for _ in 0..50 {
if let Ok(b) = std::fs::read_to_string(&file)
&& b.matches("\"kind\":\"request\"").count() >= 2
{
body = b;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(!body.contains(secret), "raw args leaked: {body}");
assert!(body.contains("\"method\":\"tools/call\""));
assert!(body.contains("\"tool\":\"read_file\""));
assert!(body.contains("blake3:"));
assert!(body.contains("\"bytes_out\":6210"));
assert!(body.contains("\"status\":\"ok\""));
assert!(body.contains("\"peer\":\"bob\""));
assert!(body.contains("\"method\":\"notifications/progress\""));
}
#[tokio::test]
async fn server_initiated_request_does_not_corrupt_client_correlation() {
let dir = tempfile::tempdir().unwrap();
let sink = AuditSink::new(AuditLog::spawn(dir.path().to_path_buf()));
let auditor = RequestAuditor::new(sink, Some("bob".into()), "notes".into());
auditor.on_request(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "read_file", "arguments": {"path": "/x"}}
}));
auditor.on_response(
&json!({"jsonrpc": "2.0", "id": 1, "method": "sampling/createMessage", "params": {}}),
50,
);
auditor.on_response(&json!({"jsonrpc": "2.0", "id": 1, "result": {}}), 300);
let month = &crate::audit::now_ts()[..7];
let file = dir.path().join(format!("{month}.jsonl"));
let mut body = String::new();
for _ in 0..50 {
if let Ok(b) = std::fs::read_to_string(&file)
&& b.contains("\"tool\":\"read_file\"")
{
body = b;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(body.contains("\"tool\":\"read_file\""));
assert!(
body.contains("\"bytes_out\":300"),
"correlated to the client's response, not the server request"
);
assert!(
!body.contains("sampling/createMessage"),
"server-initiated request must not be logged"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn record_fans_out_to_a_live_subscriber() {
let dir = tempfile::tempdir().unwrap();
let log = AuditLog::spawn(dir.path().to_path_buf());
let mut rx = log.subscribe();
log.record(AuditRecord::session_open(
now_ts(),
Some("bob".into()),
"notes".into(),
));
let got = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(got.kind, crate::audit::record::AuditKind::SessionOpen);
assert_eq!(got.peer.as_deref(), Some("bob"));
}
#[tokio::test(flavor = "multi_thread")]
async fn session_guard_tracks_active_sessions() {
let dir = tempfile::tempdir().unwrap();
let sink = AuditSink::new(AuditLog::spawn(dir.path().to_path_buf()));
assert!(sink.active_sessions().is_empty());
{
let _s = sink.session("bob".into(), "notes".into());
let live = sink.active_sessions();
assert_eq!(live.len(), 1);
assert_eq!(live[0].peer, "bob");
assert_eq!(live[0].service, "notes");
}
assert!(sink.active_sessions().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn session_guard_removes_only_its_own_row() {
let dir = tempfile::tempdir().unwrap();
let sink = AuditSink::new(AuditLog::spawn(dir.path().to_path_buf()));
let a = sink.session("alice".into(), "notes".into());
let b = sink.session("bob".into(), "notes".into());
assert_eq!(sink.active_sessions().len(), 2);
drop(a);
let live = sink.active_sessions();
assert_eq!(live.len(), 1);
assert_eq!(live[0].peer, "bob");
drop(b);
assert!(sink.active_sessions().is_empty());
}
#[test]
fn disabled_sink_session_is_a_noop() {
let sink = AuditSink::disabled();
let _s = sink.session("bob".into(), "notes".into());
assert!(sink.active_sessions().is_empty()); }
#[tokio::test]
async fn request_auditor_marks_error_responses() {
let dir = tempfile::tempdir().unwrap();
let sink = AuditSink::new(AuditLog::spawn(dir.path().to_path_buf()));
let auditor = RequestAuditor::new(sink, Some("bob".into()), "notes".into());
auditor
.on_request(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}));
auditor.on_response(
&json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "boom"}}),
120,
);
let month = &crate::audit::now_ts()[..7];
let file = dir.path().join(format!("{month}.jsonl"));
let mut ok = false;
for _ in 0..50 {
if let Ok(b) = std::fs::read_to_string(&file)
&& b.contains("\"status\":\"error\"")
{
ok = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(ok, "an error response records status=error");
}
}