1use std::fs::OpenOptions;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
20pub struct ProviderAuditEntry {
21 pub timestamp: String,
23 pub plugin_id: String,
24 pub provider_id: String,
25 pub model_id: String,
26 pub tools_exposed: bool,
28 #[serde(default)]
31 pub tools_requested: u32,
32 #[serde(default)]
34 pub streamed: bool,
35 pub outcome: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub error_class: Option<String>,
41}
42
43pub fn audit_file_path() -> PathBuf {
46 audit_file_path_for(&crate::config::base_dir())
47}
48
49pub(crate) fn audit_file_path_for(base: &Path) -> PathBuf {
51 base.join("extensions").join("audit.jsonl")
52}
53
54#[allow(clippy::too_many_arguments)]
56pub fn new_audit_entry(
57 plugin_id: impl Into<String>,
58 provider_id: impl Into<String>,
59 model_id: impl Into<String>,
60 tools_exposed: bool,
61 tools_requested: u32,
62 streamed: bool,
63 outcome: impl Into<String>,
64 error_class: Option<String>,
65) -> ProviderAuditEntry {
66 ProviderAuditEntry {
67 timestamp: chrono::Utc::now().to_rfc3339(),
68 plugin_id: plugin_id.into(),
69 provider_id: provider_id.into(),
70 model_id: model_id.into(),
71 tools_exposed,
72 tools_requested,
73 streamed,
74 outcome: outcome.into(),
75 error_class,
76 }
77}
78
79pub fn append_audit_entry(entry: &ProviderAuditEntry) -> Result<(), String> {
82 append_audit_entry_to(&crate::config::base_dir(), entry)
83}
84
85pub(crate) fn append_audit_entry_to(
87 base: &Path,
88 entry: &ProviderAuditEntry,
89) -> Result<(), String> {
90 let path = audit_file_path_for(base);
91 let parent = path
92 .parent()
93 .ok_or_else(|| format!("audit.jsonl path has no parent: {}", path.display()))?;
94 std::fs::create_dir_all(parent)
95 .map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
96
97 let mut line = serde_json::to_string(entry)
98 .map_err(|e| format!("failed to serialize audit entry: {}", e))?;
99 line.push('\n');
100
101 let mut file = OpenOptions::new()
102 .create(true)
103 .append(true)
104 .open(&path)
105 .map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
106 file.write_all(line.as_bytes())
107 .map_err(|e| format!("failed to append to {}: {}", path.display(), e))?;
108 Ok(())
109}
110
111#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
117pub struct InterceptAuditEntry {
118 pub timestamp: String,
120 pub plugin_id: String,
121 pub hook: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub tool_name: Option<String>,
126 pub action: String,
128 pub observed_len: usize,
131 pub replacement_len: usize,
133}
134
135pub fn intercept_file_path() -> PathBuf {
137 intercept_file_path_for(&crate::config::base_dir())
138}
139
140pub(crate) fn intercept_file_path_for(base: &Path) -> PathBuf {
141 base.join("extensions").join("intercept.jsonl")
142}
143
144pub fn record_tool_output_replace(
148 plugin_id: impl Into<String>,
149 hook: impl Into<String>,
150 tool_name: Option<String>,
151 observed_len: usize,
152 replacement_len: usize,
153) -> Result<(), String> {
154 record_tool_output_replace_to(
155 &crate::config::base_dir(),
156 plugin_id,
157 hook,
158 tool_name,
159 observed_len,
160 replacement_len,
161 )
162}
163
164pub(crate) fn record_tool_output_replace_to(
165 base: &Path,
166 plugin_id: impl Into<String>,
167 hook: impl Into<String>,
168 tool_name: Option<String>,
169 observed_len: usize,
170 replacement_len: usize,
171) -> Result<(), String> {
172 let entry = InterceptAuditEntry {
173 timestamp: chrono::Utc::now().to_rfc3339(),
174 plugin_id: plugin_id.into(),
175 hook: hook.into(),
176 tool_name,
177 action: "replace".to_string(),
178 observed_len,
179 replacement_len,
180 };
181 let path = intercept_file_path_for(base);
182 let parent = path
183 .parent()
184 .ok_or_else(|| format!("intercept.jsonl path has no parent: {}", path.display()))?;
185 std::fs::create_dir_all(parent)
186 .map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
187 let mut line = serde_json::to_string(&entry)
188 .map_err(|e| format!("failed to serialize intercept entry: {}", e))?;
189 line.push('\n');
190 let mut file = OpenOptions::new()
191 .create(true)
192 .append(true)
193 .open(&path)
194 .map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
195 file.write_all(line.as_bytes())
196 .map_err(|e| format!("failed to append to {}: {}", path.display(), e))?;
197 Ok(())
198}
199
200pub fn read_audit_entries() -> Result<Vec<ProviderAuditEntry>, String> {
203 read_audit_entries_from(&crate::config::base_dir())
204}
205
206pub(crate) fn read_audit_entries_from(
208 base: &Path,
209) -> Result<Vec<ProviderAuditEntry>, String> {
210 let path = audit_file_path_for(base);
211 let contents = match std::fs::read_to_string(&path) {
212 Ok(s) => s,
213 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
214 Err(e) => {
215 return Err(format!(
216 "failed to read audit.jsonl at {}: {}",
217 path.display(),
218 e
219 ));
220 }
221 };
222 let mut entries = Vec::new();
223 for (idx, raw) in contents.lines().enumerate() {
224 let line = raw.trim();
225 if line.is_empty() {
226 continue;
227 }
228 match serde_json::from_str::<ProviderAuditEntry>(line) {
229 Ok(entry) => entries.push(entry),
230 Err(e) => {
231 tracing::warn!(
232 target: "synaps::extensions::audit",
233 "skipping malformed audit.jsonl line {} at {}: {}",
234 idx + 1,
235 path.display(),
236 e
237 );
238 }
239 }
240 }
241 Ok(entries)
242}
243
244pub fn read_audit_entries_tail(n: usize) -> Result<Vec<ProviderAuditEntry>, String> {
249 read_audit_entries_tail_from(&crate::config::base_dir(), n)
250}
251
252pub(crate) fn read_audit_entries_tail_from(
254 base: &Path,
255 n: usize,
256) -> Result<Vec<ProviderAuditEntry>, String> {
257 if n == 0 {
258 return Ok(Vec::new());
259 }
260 let path = audit_file_path_for(base);
261 let contents = match std::fs::read_to_string(&path) {
262 Ok(s) => s,
263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
264 Err(e) => {
265 return Err(format!(
266 "failed to read audit.jsonl at {}: {}",
267 path.display(),
268 e
269 ));
270 }
271 };
272 let tail_lines: Vec<&str> = contents
275 .lines()
276 .filter(|l| !l.trim().is_empty())
277 .collect::<Vec<_>>()
278 .into_iter()
279 .rev()
280 .take(n)
281 .collect::<Vec<_>>()
282 .into_iter()
283 .rev()
284 .collect();
285
286 let mut entries = Vec::with_capacity(tail_lines.len());
287 for raw in tail_lines {
288 let line = raw.trim();
289 match serde_json::from_str::<ProviderAuditEntry>(line) {
290 Ok(entry) => entries.push(entry),
291 Err(e) => {
292 tracing::warn!(
293 target: "synaps::extensions::audit",
294 "skipping malformed audit.jsonl tail line at {}: {}",
295 path.display(),
296 e
297 );
298 }
299 }
300 }
301 Ok(entries)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use tempfile::TempDir;
308
309 fn sample(plugin: &str, outcome: &str) -> ProviderAuditEntry {
310 ProviderAuditEntry {
311 timestamp: "2025-01-01T00:00:00Z".to_string(),
312 plugin_id: plugin.to_string(),
313 provider_id: "p".to_string(),
314 model_id: "m".to_string(),
315 tools_exposed: false,
316 tools_requested: 0,
317 streamed: false,
318 outcome: outcome.to_string(),
319 error_class: None,
320 }
321 }
322
323 #[test]
324 fn audit_file_path_is_under_extensions_dir() {
325 let dir = TempDir::new().unwrap();
326 let p = audit_file_path_for(dir.path());
327 assert_eq!(p, dir.path().join("extensions").join("audit.jsonl"));
328 }
329
330 #[test]
331 fn append_two_entries_then_read_returns_them_in_order() {
332 let dir = TempDir::new().unwrap();
333 let a = sample("plug-a", "ok");
334 let b = sample("plug-b", "blocked");
335 append_audit_entry_to(dir.path(), &a).unwrap();
336 append_audit_entry_to(dir.path(), &b).unwrap();
337 let entries = read_audit_entries_from(dir.path()).unwrap();
338 assert_eq!(entries, vec![a, b]);
339 }
340
341 #[test]
342 fn read_missing_file_returns_empty() {
343 let dir = TempDir::new().unwrap();
344 let entries = read_audit_entries_from(dir.path()).unwrap();
345 assert!(entries.is_empty());
346 }
347
348 #[test]
349 fn intercept_path_is_under_extensions_dir() {
350 let dir = TempDir::new().unwrap();
351 assert_eq!(
352 intercept_file_path_for(dir.path()),
353 dir.path().join("extensions").join("intercept.jsonl")
354 );
355 }
356
357 #[test]
358 fn record_tool_output_replace_writes_metadata_only() {
359 let dir = TempDir::new().unwrap();
360 record_tool_output_replace_to(
361 dir.path(),
362 "headroom-bridge",
363 "after_tool_call",
364 Some("bash".to_string()),
365 45_000,
366 6_080,
367 )
368 .unwrap();
369 let raw = std::fs::read_to_string(intercept_file_path_for(dir.path())).unwrap();
370 let entry: InterceptAuditEntry = serde_json::from_str(raw.trim()).unwrap();
371 assert_eq!(entry.plugin_id, "headroom-bridge");
372 assert_eq!(entry.hook, "after_tool_call");
373 assert_eq!(entry.tool_name.as_deref(), Some("bash"));
374 assert_eq!(entry.action, "replace");
375 assert_eq!(entry.observed_len, 45_000);
376 assert_eq!(entry.replacement_len, 6_080);
377 assert!(!raw.contains("tool_output"), "audit line must not carry content");
379 }
380
381 #[test]
382 fn malformed_line_in_middle_is_skipped() {
383 let dir = TempDir::new().unwrap();
384 let a = sample("plug-a", "ok");
385 let c = sample("plug-c", "error");
386 append_audit_entry_to(dir.path(), &a).unwrap();
387 let path = audit_file_path_for(dir.path());
389 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
390 f.write_all(b"{ this is not valid json\n").unwrap();
391 drop(f);
392 append_audit_entry_to(dir.path(), &c).unwrap();
393
394 let entries = read_audit_entries_from(dir.path()).unwrap();
395 assert_eq!(entries, vec![a, c]);
396 }
397
398 #[test]
399 fn concurrent_appenders_produce_full_record_count() {
400 let dir = TempDir::new().unwrap();
401 let base = dir.path().to_path_buf();
402 let mut handles = Vec::new();
403 for t in 0..4u32 {
404 let base = base.clone();
405 handles.push(std::thread::spawn(move || {
406 for i in 0..10u32 {
407 let mut e = sample(&format!("plug-{t}"), "ok");
408 e.tools_requested = i;
409 append_audit_entry_to(&base, &e).expect("append");
410 }
411 }));
412 }
413 for h in handles {
414 h.join().unwrap();
415 }
416 let entries = read_audit_entries_from(&base).unwrap();
417 assert_eq!(entries.len(), 40);
418 }
419
420 #[test]
421 fn new_audit_entry_produces_rfc3339_timestamp() {
422 let e = new_audit_entry(
423 "plug",
424 "prov",
425 "model",
426 true,
427 0,
428 false,
429 "ok",
430 None,
431 );
432 let ts = &e.timestamp;
434 assert!(ts.len() >= 20, "timestamp too short: {ts}");
435 assert!(
436 ts.chars().take(4).all(|c| c.is_ascii_digit()),
437 "expected 4-digit year: {ts}"
438 );
439 assert!(ts.contains('T'), "expected 'T' separator: {ts}");
440 assert!(
441 ts.ends_with('Z') || ts.contains('+') || ts[10..].contains('-'),
442 "expected timezone suffix: {ts}"
443 );
444 chrono::DateTime::parse_from_rfc3339(ts)
446 .unwrap_or_else(|err| panic!("parse_from_rfc3339({ts}) failed: {err}"));
447 }
448
449 #[test]
450 fn round_trip_with_error_class_omitted_when_none() {
451 let dir = TempDir::new().unwrap();
452 let mut e = sample("plug", "ok");
453 e.error_class = None;
454 append_audit_entry_to(dir.path(), &e).unwrap();
455 let raw = std::fs::read_to_string(audit_file_path_for(dir.path())).unwrap();
456 assert!(
457 !raw.contains("error_class"),
458 "error_class should be skipped when None: {raw}"
459 );
460 let loaded = read_audit_entries_from(dir.path()).unwrap();
461 assert_eq!(loaded, vec![e]);
462 }
463}