use tsafe_core::audit::{AuditContext, AuditEntry, AuditLog, AuditMcpContext, AuditStatus};
use tsafe_core::profile;
use crate::session::Session;
#[derive(Debug, Clone, Copy)]
pub enum CallStatus {
Success,
Failure,
}
#[allow(clippy::too_many_arguments)]
pub fn audit_call(
session: &Session,
tool: &str,
key: Option<&str>,
injected_keys: Vec<String>,
exit_code: Option<i32>,
duration_ms: Option<u64>,
status: CallStatus,
message: Option<&str>,
) {
let operation = mcp_operation(tool);
let mut entry = match status {
CallStatus::Success => AuditEntry::success(&session.profile, &operation, key),
CallStatus::Failure => AuditEntry::failure(
&session.profile,
&operation,
key,
message.unwrap_or("(unspecified failure)"),
),
};
entry = entry.with_context(AuditContext::from_mcp(AuditMcpContext {
host: session.audit_source.clone(),
pid: session.pid,
tool: tool.to_string(),
injected_keys,
exit_code,
duration_ms,
}));
debug_assert!(matches!(
entry.status,
AuditStatus::Success | AuditStatus::Failure
));
let log = AuditLog::new(&profile::audit_log_path(&session.profile));
if let Err(e) = log.append(&entry) {
tracing::warn!(
target: "tsafe_mcp::audit",
error = %e,
operation = %operation,
"audit append failed (continuing)"
);
}
}
fn mcp_operation(tool: &str) -> String {
if let Some(rest) = tool.strip_prefix("tsafe_") {
format!("mcp.{rest}")
} else {
format!("mcp.{tool}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn session_with_profile(profile: &str) -> Session {
Session {
profile: profile.to_string(),
allowed_globs: vec!["demo/*".to_string()],
denied_globs: vec![],
contract: None,
allow_reveal: false,
audit_source: "mcp:test:9999".to_string(),
pid: 9999,
require_agent: false,
vault_path: PathBuf::from("nonexistent"),
}
}
#[test]
fn mcp_operation_strips_tsafe_prefix() {
assert_eq!(mcp_operation("tsafe_run"), "mcp.run");
assert_eq!(mcp_operation("tsafe_list_keys"), "mcp.list_keys");
assert_eq!(mcp_operation("tsafe_reveal"), "mcp.reveal");
assert_eq!(mcp_operation("other"), "mcp.other");
}
#[test]
fn audit_call_writes_jsonl_row_with_mcp_context() {
let tmp = tempfile::tempdir().unwrap();
let vault_dir = tmp.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
let profile_name = "mcp_audit_singleton_test";
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
let session = session_with_profile(profile_name);
let log_path = profile::audit_log_path(profile_name);
let _ = std::fs::remove_file(&log_path);
audit_call(
&session,
"tsafe_run",
None,
vec!["demo/foo".to_string()],
Some(0),
Some(15),
CallStatus::Success,
None,
);
let log = AuditLog::new(&log_path);
let entries = log.read(None).unwrap();
assert_eq!(entries.len(), 1, "expected exactly one audit entry");
let e = &entries[0];
assert_eq!(e.operation, "mcp.run");
assert!(matches!(e.status, AuditStatus::Success));
let ctx = e.context.as_ref().unwrap();
let mcp = ctx.mcp.as_ref().unwrap();
assert_eq!(mcp.tool, "tsafe_run");
assert_eq!(mcp.host, "mcp:test:9999");
assert_eq!(mcp.injected_keys, vec!["demo/foo".to_string()]);
assert_eq!(mcp.exit_code, Some(0));
assert_eq!(mcp.duration_ms, Some(15));
});
}
#[test]
fn audit_call_records_failure_status_with_message() {
let tmp = tempfile::tempdir().unwrap();
let vault_dir = tmp.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
let profile_name = "mcp_audit_failure_test";
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
let session = session_with_profile(profile_name);
let log_path = profile::audit_log_path(profile_name);
let _ = std::fs::remove_file(&log_path);
audit_call(
&session,
"tsafe_reveal",
Some("demo/forbidden"),
Vec::new(),
None,
None,
CallStatus::Failure,
Some("key 'demo/forbidden' is outside the configured scope"),
);
let log = AuditLog::new(&log_path);
let entries = log.read(None).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.operation, "mcp.reveal");
assert!(
matches!(e.status, AuditStatus::Failure),
"expected Failure status, got {:?}",
e.status
);
assert_eq!(e.key.as_deref(), Some("demo/forbidden"));
let mcp = e.context.as_ref().unwrap().mcp.as_ref().unwrap();
assert_eq!(mcp.tool, "tsafe_reveal");
});
}
}