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(base: &Path, entry: &ProviderAuditEntry) -> Result<(), String> {
87 let path = audit_file_path_for(base);
88 let parent = path
89 .parent()
90 .ok_or_else(|| format!("audit.jsonl path has no parent: {}", path.display()))?;
91 std::fs::create_dir_all(parent)
92 .map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
93
94 let mut line = serde_json::to_string(entry)
95 .map_err(|e| format!("failed to serialize audit entry: {}", e))?;
96 line.push('\n');
97
98 let mut file = OpenOptions::new()
99 .create(true)
100 .append(true)
101 .open(&path)
102 .map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
103 file.write_all(line.as_bytes())
104 .map_err(|e| format!("failed to append to {}: {}", path.display(), e))?;
105 Ok(())
106}
107
108#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
114pub struct InterceptAuditEntry {
115 pub timestamp: String,
117 pub plugin_id: String,
118 pub hook: String,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub tool_name: Option<String>,
123 pub action: String,
125 pub observed_len: usize,
128 pub replacement_len: usize,
130}
131
132pub fn intercept_file_path() -> PathBuf {
134 intercept_file_path_for(&crate::config::base_dir())
135}
136
137pub(crate) fn intercept_file_path_for(base: &Path) -> PathBuf {
138 base.join("extensions").join("intercept.jsonl")
139}
140
141pub fn record_tool_output_replace(
145 plugin_id: impl Into<String>,
146 hook: impl Into<String>,
147 tool_name: Option<String>,
148 observed_len: usize,
149 replacement_len: usize,
150) -> Result<(), String> {
151 #[cfg(test)]
152 assert_test_base_dir_isolated("record_tool_output_replace");
153 record_tool_output_replace_to(
154 &crate::config::base_dir(),
155 plugin_id,
156 hook,
157 tool_name,
158 observed_len,
159 replacement_len,
160 )
161}
162
163#[cfg(test)]
169fn assert_test_base_dir_isolated(writer: &str) {
170 assert!(
171 std::env::var_os("SYNAPS_BASE_DIR").is_some(),
172 "{writer} called from a test without an isolated base dir — it would write \
173 under the real ~/.synaps-cli. Hold `crate::test_env::BaseDirGuard` and mark \
174 the test `#[serial_test::serial(synaps_base_dir)]`."
175 );
176}
177
178pub(crate) fn record_tool_output_replace_to(
179 base: &Path,
180 plugin_id: impl Into<String>,
181 hook: impl Into<String>,
182 tool_name: Option<String>,
183 observed_len: usize,
184 replacement_len: usize,
185) -> Result<(), String> {
186 let entry = InterceptAuditEntry {
187 timestamp: chrono::Utc::now().to_rfc3339(),
188 plugin_id: plugin_id.into(),
189 hook: hook.into(),
190 tool_name,
191 action: "replace".to_string(),
192 observed_len,
193 replacement_len,
194 };
195 let path = intercept_file_path_for(base);
196 let parent = path
197 .parent()
198 .ok_or_else(|| format!("intercept.jsonl path has no parent: {}", path.display()))?;
199 std::fs::create_dir_all(parent)
200 .map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
201 let mut line = serde_json::to_string(&entry)
202 .map_err(|e| format!("failed to serialize intercept entry: {}", e))?;
203 line.push('\n');
204 let mut file = OpenOptions::new()
205 .create(true)
206 .append(true)
207 .open(&path)
208 .map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
209 file.write_all(line.as_bytes())
210 .map_err(|e| format!("failed to append to {}: {}", path.display(), e))?;
211 Ok(())
212}
213
214pub fn read_audit_entries() -> Result<Vec<ProviderAuditEntry>, String> {
217 read_audit_entries_from(&crate::config::base_dir())
218}
219
220pub(crate) fn read_audit_entries_from(base: &Path) -> Result<Vec<ProviderAuditEntry>, String> {
222 let path = audit_file_path_for(base);
223 let contents = match std::fs::read_to_string(&path) {
224 Ok(s) => s,
225 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
226 Err(e) => {
227 return Err(format!(
228 "failed to read audit.jsonl at {}: {}",
229 path.display(),
230 e
231 ));
232 }
233 };
234 let mut entries = Vec::new();
235 for (idx, raw) in contents.lines().enumerate() {
236 let line = raw.trim();
237 if line.is_empty() {
238 continue;
239 }
240 match serde_json::from_str::<ProviderAuditEntry>(line) {
241 Ok(entry) => entries.push(entry),
242 Err(e) => {
243 tracing::warn!(
244 target: "synaps::extensions::audit",
245 "skipping malformed audit.jsonl line {} at {}: {}",
246 idx + 1,
247 path.display(),
248 e
249 );
250 }
251 }
252 }
253 Ok(entries)
254}
255
256pub fn read_audit_entries_tail(n: usize) -> Result<Vec<ProviderAuditEntry>, String> {
261 read_audit_entries_tail_from(&crate::config::base_dir(), n)
262}
263
264pub(crate) fn read_audit_entries_tail_from(
266 base: &Path,
267 n: usize,
268) -> Result<Vec<ProviderAuditEntry>, String> {
269 if n == 0 {
270 return Ok(Vec::new());
271 }
272 let path = audit_file_path_for(base);
273 let contents = match std::fs::read_to_string(&path) {
274 Ok(s) => s,
275 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
276 Err(e) => {
277 return Err(format!(
278 "failed to read audit.jsonl at {}: {}",
279 path.display(),
280 e
281 ));
282 }
283 };
284 let tail_lines: Vec<&str> = contents
287 .lines()
288 .filter(|l| !l.trim().is_empty())
289 .collect::<Vec<_>>()
290 .into_iter()
291 .rev()
292 .take(n)
293 .collect::<Vec<_>>()
294 .into_iter()
295 .rev()
296 .collect();
297
298 let mut entries = Vec::with_capacity(tail_lines.len());
299 for raw in tail_lines {
300 let line = raw.trim();
301 match serde_json::from_str::<ProviderAuditEntry>(line) {
302 Ok(entry) => entries.push(entry),
303 Err(e) => {
304 tracing::warn!(
305 target: "synaps::extensions::audit",
306 "skipping malformed audit.jsonl tail line at {}: {}",
307 path.display(),
308 e
309 );
310 }
311 }
312 }
313 Ok(entries)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use tempfile::TempDir;
320
321 fn sample(plugin: &str, outcome: &str) -> ProviderAuditEntry {
322 ProviderAuditEntry {
323 timestamp: "2025-01-01T00:00:00Z".to_string(),
324 plugin_id: plugin.to_string(),
325 provider_id: "p".to_string(),
326 model_id: "m".to_string(),
327 tools_exposed: false,
328 tools_requested: 0,
329 streamed: false,
330 outcome: outcome.to_string(),
331 error_class: None,
332 }
333 }
334
335 #[test]
336 fn audit_file_path_is_under_extensions_dir() {
337 let dir = TempDir::new().unwrap();
338 let p = audit_file_path_for(dir.path());
339 assert_eq!(p, dir.path().join("extensions").join("audit.jsonl"));
340 }
341
342 #[test]
343 fn append_two_entries_then_read_returns_them_in_order() {
344 let dir = TempDir::new().unwrap();
345 let a = sample("plug-a", "ok");
346 let b = sample("plug-b", "blocked");
347 append_audit_entry_to(dir.path(), &a).unwrap();
348 append_audit_entry_to(dir.path(), &b).unwrap();
349 let entries = read_audit_entries_from(dir.path()).unwrap();
350 assert_eq!(entries, vec![a, b]);
351 }
352
353 #[test]
354 fn read_missing_file_returns_empty() {
355 let dir = TempDir::new().unwrap();
356 let entries = read_audit_entries_from(dir.path()).unwrap();
357 assert!(entries.is_empty());
358 }
359
360 #[test]
361 fn intercept_path_is_under_extensions_dir() {
362 let dir = TempDir::new().unwrap();
363 assert_eq!(
364 intercept_file_path_for(dir.path()),
365 dir.path().join("extensions").join("intercept.jsonl")
366 );
367 }
368
369 #[test]
370 fn record_tool_output_replace_writes_metadata_only() {
371 let dir = TempDir::new().unwrap();
372 record_tool_output_replace_to(
373 dir.path(),
374 "headroom-bridge",
375 "after_tool_call",
376 Some("bash".to_string()),
377 45_000,
378 6_080,
379 )
380 .unwrap();
381 let raw = std::fs::read_to_string(intercept_file_path_for(dir.path())).unwrap();
382 let entry: InterceptAuditEntry = serde_json::from_str(raw.trim()).unwrap();
383 assert_eq!(entry.plugin_id, "headroom-bridge");
384 assert_eq!(entry.hook, "after_tool_call");
385 assert_eq!(entry.tool_name.as_deref(), Some("bash"));
386 assert_eq!(entry.action, "replace");
387 assert_eq!(entry.observed_len, 45_000);
388 assert_eq!(entry.replacement_len, 6_080);
389 assert!(
391 !raw.contains("tool_output"),
392 "audit line must not carry content"
393 );
394 }
395
396 #[test]
399 #[serial_test::serial(synaps_base_dir)]
400 #[should_panic(expected = "without an isolated base dir")]
401 fn record_tool_output_replace_rejects_unisolated_test_write() {
402 let _restore = crate::test_env::BaseDirGuard::new();
404 std::env::remove_var("SYNAPS_BASE_DIR");
405 let _ = record_tool_output_replace("tripwire", "after_tool_call", None, 1, 1);
406 }
407
408 #[test]
409 fn malformed_line_in_middle_is_skipped() {
410 let dir = TempDir::new().unwrap();
411 let a = sample("plug-a", "ok");
412 let c = sample("plug-c", "error");
413 append_audit_entry_to(dir.path(), &a).unwrap();
414 let path = audit_file_path_for(dir.path());
416 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
417 f.write_all(b"{ this is not valid json\n").unwrap();
418 drop(f);
419 append_audit_entry_to(dir.path(), &c).unwrap();
420
421 let entries = read_audit_entries_from(dir.path()).unwrap();
422 assert_eq!(entries, vec![a, c]);
423 }
424
425 #[test]
426 fn concurrent_appenders_produce_full_record_count() {
427 let dir = TempDir::new().unwrap();
428 let base = dir.path().to_path_buf();
429 let mut handles = Vec::new();
430 for t in 0..4u32 {
431 let base = base.clone();
432 handles.push(std::thread::spawn(move || {
433 for i in 0..10u32 {
434 let mut e = sample(&format!("plug-{t}"), "ok");
435 e.tools_requested = i;
436 append_audit_entry_to(&base, &e).expect("append");
437 }
438 }));
439 }
440 for h in handles {
441 h.join().unwrap();
442 }
443 let entries = read_audit_entries_from(&base).unwrap();
444 assert_eq!(entries.len(), 40);
445 }
446
447 #[test]
448 fn new_audit_entry_produces_rfc3339_timestamp() {
449 let e = new_audit_entry("plug", "prov", "model", true, 0, false, "ok", None);
450 let ts = &e.timestamp;
452 assert!(ts.len() >= 20, "timestamp too short: {ts}");
453 assert!(
454 ts.chars().take(4).all(|c| c.is_ascii_digit()),
455 "expected 4-digit year: {ts}"
456 );
457 assert!(ts.contains('T'), "expected 'T' separator: {ts}");
458 assert!(
459 ts.ends_with('Z') || ts.contains('+') || ts[10..].contains('-'),
460 "expected timezone suffix: {ts}"
461 );
462 chrono::DateTime::parse_from_rfc3339(ts)
464 .unwrap_or_else(|err| panic!("parse_from_rfc3339({ts}) failed: {err}"));
465 }
466
467 #[test]
468 fn round_trip_with_error_class_omitted_when_none() {
469 let dir = TempDir::new().unwrap();
470 let mut e = sample("plug", "ok");
471 e.error_class = None;
472 append_audit_entry_to(dir.path(), &e).unwrap();
473 let raw = std::fs::read_to_string(audit_file_path_for(dir.path())).unwrap();
474 assert!(
475 !raw.contains("error_class"),
476 "error_class should be skipped when None: {raw}"
477 );
478 let loaded = read_audit_entries_from(dir.path()).unwrap();
479 assert_eq!(loaded, vec![e]);
480 }
481}