use serde_json::{json, Value};
use tsafe_core::audit::{AuditLog, AuditStatus};
use tsafe_core::profile;
use crate::audit::{audit_call, CallStatus};
use crate::errors::{McpError, McpErrorKind};
use crate::session::Session;
const DEFAULT_LIMIT: usize = 50;
const MIN_LIMIT: usize = 1;
const MAX_LIMIT: usize = 500;
pub fn call(session: &Session, raw: Value) -> Result<Value, McpError> {
let limit = parse_limit(&raw)?;
let log_path = profile::audit_log_path(&session.profile);
if !log_path.exists() {
audit_call(
session,
"tsafe_audit_tail",
None,
Vec::new(),
None,
None,
CallStatus::Success,
None,
);
return Ok(json!([]));
}
let log = AuditLog::new(&log_path);
let entries = log.read(Some(limit)).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("audit log read failed: {e}"),
)
})?;
let redacted: Vec<Value> = entries
.iter()
.map(|e| {
let source = e
.context
.as_ref()
.and_then(|c| c.mcp.as_ref().map(|m| m.host.clone()));
let status = match e.status {
AuditStatus::Success => "success",
AuditStatus::Failure => "failure",
};
json!({
"id": e.id,
"timestamp": e.timestamp,
"operation": e.operation,
"key": e.key,
"status": status,
"source": source,
})
})
.collect();
audit_call(
session,
"tsafe_audit_tail",
None,
Vec::new(),
None,
None,
CallStatus::Success,
None,
);
Ok(json!(redacted))
}
fn parse_limit(raw: &Value) -> Result<usize, McpError> {
let obj = match raw.as_object() {
Some(o) => o,
None if raw.is_null() => return Ok(DEFAULT_LIMIT),
None => {
return Err(McpError::new(
McpErrorKind::InvalidParams,
"expected an object",
))
}
};
for k in obj.keys() {
if k != "limit" {
return Err(McpError::new(
McpErrorKind::InvalidParams,
format!("unknown field '{k}'"),
));
}
}
let limit = match obj.get("limit") {
Some(v) => {
let n = v.as_u64().ok_or_else(|| {
McpError::new(
McpErrorKind::InvalidParams,
"'limit' must be a positive integer",
)
})? as usize;
if !(MIN_LIMIT..=MAX_LIMIT).contains(&n) {
return Err(McpError::new(
McpErrorKind::InvalidParams,
format!("'limit' must be in {MIN_LIMIT}..={MAX_LIMIT}"),
));
}
n
}
None => DEFAULT_LIMIT,
};
Ok(limit)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::PathBuf;
use tsafe_core::audit::{AuditContext, AuditEntry, AuditMcpContext};
fn session(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:1".to_string(),
pid: 1,
require_agent: false,
vault_path: PathBuf::from("nonexistent"),
}
}
fn isolated<F: FnOnce()>(f: F) {
let tmp = tempfile::tempdir().unwrap();
let vault_dir = tmp.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), f);
}
#[test]
fn parse_limit_validates_range() {
assert!(parse_limit(&json!({"limit": 0})).is_err());
assert!(parse_limit(&json!({"limit": 9999})).is_err());
assert_eq!(parse_limit(&json!({"limit": 25})).unwrap(), 25);
assert_eq!(parse_limit(&json!({})).unwrap(), DEFAULT_LIMIT);
}
#[test]
fn missing_log_returns_empty_array() {
isolated(|| {
let resp = call(&session("missing_profile"), json!({})).unwrap();
assert!(resp.is_array());
assert!(resp.as_array().unwrap().is_empty());
});
}
#[test]
fn redaction_surfaces_only_six_fields() {
isolated(|| {
let profile = "audit_tail_test";
let log_path = profile::audit_log_path(profile);
let log = AuditLog::new(&log_path);
for i in 0..3 {
let entry = AuditEntry::success(profile, "mcp.list_keys", None).with_context(
AuditContext::from_mcp(AuditMcpContext {
host: format!("mcp:test:{i}"),
pid: i,
tool: "tsafe_list_keys".to_string(),
injected_keys: vec![],
exit_code: None,
duration_ms: None,
}),
);
log.append(&entry).unwrap();
}
let resp = call(&session(profile), json!({"limit": 2})).unwrap();
let arr = resp.as_array().unwrap();
assert_eq!(arr.len(), 2);
for r in arr {
let obj = r.as_object().unwrap();
let keys: Vec<&String> = obj.keys().collect();
assert_eq!(keys.len(), 6, "expected exactly 6 fields, got {keys:?}");
assert!(obj.contains_key("id"));
assert!(obj.contains_key("timestamp"));
assert!(obj.contains_key("operation"));
assert!(obj.contains_key("key"));
assert!(obj.contains_key("status"));
assert!(obj.contains_key("source"));
assert!(obj["source"].as_str().unwrap().starts_with("mcp:test:"));
}
});
}
}