agent_tools_interface/core/
audit.rs1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::io::{BufRead, Write};
5use std::path::PathBuf;
6
7use crate::core::dirs;
8use crate::core::scope::matches_wildcard;
9
10const MAX_ARG_VALUE_LEN: usize = 200;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum AuditStatus {
15 Ok,
16 Error,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AuditEntry {
21 pub ts: String,
22 pub tool: String,
23 pub args: Value,
24 pub status: AuditStatus,
25 pub duration_ms: u64,
26 pub agent_sub: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub error: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub exit_code: Option<i32>,
31}
32
33pub fn audit_file_path() -> PathBuf {
35 if let Ok(p) = std::env::var("ATI_AUDIT_FILE") {
36 PathBuf::from(p)
37 } else {
38 dirs::ati_dir().join("audit.jsonl")
39 }
40}
41
42pub fn append(entry: &AuditEntry) -> Result<(), std::io::Error> {
44 let path = audit_file_path();
45 if let Some(parent) = path.parent() {
46 std::fs::create_dir_all(parent)?;
47 }
48 let mut file = std::fs::OpenOptions::new()
49 .create(true)
50 .append(true)
51 .open(&path)?;
52 let line = serde_json::to_string(entry)
53 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
54 writeln!(file, "{}", line)?;
55 Ok(())
56}
57
58pub fn tail(n: usize) -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
60 let path = audit_file_path();
61 if !path.exists() {
62 return Ok(Vec::new());
63 }
64 let file = std::fs::File::open(&path)?;
65 let reader = std::io::BufReader::new(file);
66 let entries: Vec<AuditEntry> = reader
67 .lines()
68 .filter_map(|l| l.ok())
69 .filter(|l| !l.trim().is_empty())
70 .filter_map(|l| serde_json::from_str(&l).ok())
71 .collect();
72 let start = entries.len().saturating_sub(n);
73 Ok(entries[start..].to_vec())
74}
75
76pub fn search(
78 tool_pattern: Option<&str>,
79 since: Option<&str>,
80) -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
81 let path = audit_file_path();
82 if !path.exists() {
83 return Ok(Vec::new());
84 }
85
86 let since_ts = since.map(parse_duration_ago).transpose()?;
87
88 let file = std::fs::File::open(&path)?;
89 let reader = std::io::BufReader::new(file);
90 let entries: Vec<AuditEntry> = reader
91 .lines()
92 .filter_map(|l| l.ok())
93 .filter(|l| !l.trim().is_empty())
94 .filter_map(|l| serde_json::from_str(&l).ok())
95 .filter(|e: &AuditEntry| {
96 if let Some(pattern) = tool_pattern {
97 if !matches_wildcard(&e.tool, pattern) {
98 return false;
99 }
100 }
101 if let Some(ref cutoff) = since_ts {
102 if e.ts.as_str() < cutoff.as_str() {
103 return false;
104 }
105 }
106 true
107 })
108 .collect();
109
110 Ok(entries)
111}
112
113pub fn sanitize_args(args: &Value) -> Value {
115 match args {
116 Value::Object(map) => {
117 let mut sanitized = serde_json::Map::new();
118 for (key, value) in map {
119 let key_lower = key.to_lowercase();
120 if key_lower.contains("password")
121 || key_lower.contains("secret")
122 || key_lower.contains("token")
123 || key_lower.contains("key")
124 || key_lower.contains("credential")
125 || key_lower.contains("auth")
126 {
127 sanitized.insert(key.clone(), Value::String("[REDACTED]".to_string()));
128 } else {
129 sanitized.insert(key.clone(), truncate_value(value));
130 }
131 }
132 Value::Object(sanitized)
133 }
134 other => truncate_value(other),
135 }
136}
137
138fn truncate_value(value: &Value) -> Value {
139 match value {
140 Value::String(s) if s.len() > MAX_ARG_VALUE_LEN => {
141 Value::String(format!("{}...[truncated]", &s[..MAX_ARG_VALUE_LEN]))
142 }
143 other => other.clone(),
144 }
145}
146
147fn parse_duration_ago(s: &str) -> Result<String, Box<dyn std::error::Error>> {
150 let s = s.trim();
151 if s.is_empty() {
152 return Err("Empty duration string".into());
153 }
154
155 let split_pos = s
157 .find(|c: char| !c.is_ascii_digit())
158 .ok_or_else(|| format!("Invalid duration: '{s}'. Use format like 1h, 30m, 7d"))?;
159 let (num_str, unit) = s.split_at(split_pos);
160
161 let count: i64 = num_str
162 .parse()
163 .map_err(|_| format!("Invalid number in duration: '{s}'"))?;
164
165 let secs_per_unit = dirs::unit_to_secs(unit)
166 .ok_or_else(|| format!("Invalid duration unit: '{unit}'. Use s, m, h, or d"))?;
167
168 let seconds = count * secs_per_unit as i64;
169 let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
170 Ok(cutoff.to_rfc3339())
171}