Skip to main content

synaps_cli/extensions/
audit.rs

1//! Append-only provider invocation audit log.
2//!
3//! Records minimal metadata for each provider routing event without
4//! storing prompts or tool payloads. File: `$SYNAPS_BASE_DIR/extensions/audit.jsonl`.
5//!
6//! Design constraints:
7//!
8//! - One JSON object per line; missing file is equivalent to no entries.
9//! - Append-only: each new entry uses `O_APPEND` so concurrent appenders
10//!   on Unix produce well-formed line records without locking.
11//! - Malformed lines (e.g. partial write from a crash) are skipped on read
12//!   with a `tracing::warn!` so a corrupt line cannot lock the user out.
13//! - Never contains prompt text, tool inputs, tool outputs, or tokens.
14
15use 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    /// RFC3339 UTC timestamp.
22    pub timestamp: String,
23    pub plugin_id: String,
24    pub provider_id: String,
25    pub model_id: String,
26    /// Whether Synaps exposed any tool schemas to this invocation.
27    pub tools_exposed: bool,
28    /// Number of tool calls the provider requested during this invocation.
29    /// 0 if no tool-use loop ran.
30    #[serde(default)]
31    pub tools_requested: u32,
32    /// Whether the invocation streamed (vs. provider.complete).
33    #[serde(default)]
34    pub streamed: bool,
35    /// "ok" | "error" | "blocked" — high-level outcome.
36    pub outcome: String,
37    /// Optional short error class (e.g. "trust_disabled", "ipc_error", "timeout").
38    /// MUST NOT contain prompt or tool content.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub error_class: Option<String>,
41}
42
43/// Path to the audit file under the active base dir. Caller is responsible for
44/// creating parent directories when writing.
45pub fn audit_file_path() -> PathBuf {
46    audit_file_path_for(&crate::config::base_dir())
47}
48
49/// Path to the audit file rooted at an explicit base dir (test helper / reuse).
50pub(crate) fn audit_file_path_for(base: &Path) -> PathBuf {
51    base.join("extensions").join("audit.jsonl")
52}
53
54/// Build a fresh entry with the current UTC timestamp.
55#[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
79/// Append a single entry as one JSON line. Creates parent dirs and the file
80/// if missing. Atomic per-line via `O_APPEND` on Unix.
81pub fn append_audit_entry(entry: &ProviderAuditEntry) -> Result<(), String> {
82    append_audit_entry_to(&crate::config::base_dir(), entry)
83}
84
85/// Append an entry under an explicit base dir.
86pub(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/// Read all entries (one per line). Missing file → empty Vec. Malformed
112/// lines are skipped with a `tracing::warn!`.
113pub fn read_audit_entries() -> Result<Vec<ProviderAuditEntry>, String> {
114    read_audit_entries_from(&crate::config::base_dir())
115}
116
117/// Read entries under an explicit base dir.
118pub(crate) fn read_audit_entries_from(
119    base: &Path,
120) -> Result<Vec<ProviderAuditEntry>, String> {
121    let path = audit_file_path_for(base);
122    let contents = match std::fs::read_to_string(&path) {
123        Ok(s) => s,
124        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
125        Err(e) => {
126            return Err(format!(
127                "failed to read audit.jsonl at {}: {}",
128                path.display(),
129                e
130            ));
131        }
132    };
133    let mut entries = Vec::new();
134    for (idx, raw) in contents.lines().enumerate() {
135        let line = raw.trim();
136        if line.is_empty() {
137            continue;
138        }
139        match serde_json::from_str::<ProviderAuditEntry>(line) {
140            Ok(entry) => entries.push(entry),
141            Err(e) => {
142                tracing::warn!(
143                    target: "synaps::extensions::audit",
144                    "skipping malformed audit.jsonl line {} at {}: {}",
145                    idx + 1,
146                    path.display(),
147                    e
148                );
149            }
150        }
151    }
152    Ok(entries)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use tempfile::TempDir;
159
160    fn sample(plugin: &str, outcome: &str) -> ProviderAuditEntry {
161        ProviderAuditEntry {
162            timestamp: "2025-01-01T00:00:00Z".to_string(),
163            plugin_id: plugin.to_string(),
164            provider_id: "p".to_string(),
165            model_id: "m".to_string(),
166            tools_exposed: false,
167            tools_requested: 0,
168            streamed: false,
169            outcome: outcome.to_string(),
170            error_class: None,
171        }
172    }
173
174    #[test]
175    fn audit_file_path_is_under_extensions_dir() {
176        let dir = TempDir::new().unwrap();
177        let p = audit_file_path_for(dir.path());
178        assert_eq!(p, dir.path().join("extensions").join("audit.jsonl"));
179    }
180
181    #[test]
182    fn append_two_entries_then_read_returns_them_in_order() {
183        let dir = TempDir::new().unwrap();
184        let a = sample("plug-a", "ok");
185        let b = sample("plug-b", "blocked");
186        append_audit_entry_to(dir.path(), &a).unwrap();
187        append_audit_entry_to(dir.path(), &b).unwrap();
188        let entries = read_audit_entries_from(dir.path()).unwrap();
189        assert_eq!(entries, vec![a, b]);
190    }
191
192    #[test]
193    fn read_missing_file_returns_empty() {
194        let dir = TempDir::new().unwrap();
195        let entries = read_audit_entries_from(dir.path()).unwrap();
196        assert!(entries.is_empty());
197    }
198
199    #[test]
200    fn malformed_line_in_middle_is_skipped() {
201        let dir = TempDir::new().unwrap();
202        let a = sample("plug-a", "ok");
203        let c = sample("plug-c", "error");
204        append_audit_entry_to(dir.path(), &a).unwrap();
205        // Inject a malformed line.
206        let path = audit_file_path_for(dir.path());
207        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
208        f.write_all(b"{ this is not valid json\n").unwrap();
209        drop(f);
210        append_audit_entry_to(dir.path(), &c).unwrap();
211
212        let entries = read_audit_entries_from(dir.path()).unwrap();
213        assert_eq!(entries, vec![a, c]);
214    }
215
216    #[test]
217    fn concurrent_appenders_produce_full_record_count() {
218        let dir = TempDir::new().unwrap();
219        let base = dir.path().to_path_buf();
220        let mut handles = Vec::new();
221        for t in 0..4u32 {
222            let base = base.clone();
223            handles.push(std::thread::spawn(move || {
224                for i in 0..10u32 {
225                    let mut e = sample(&format!("plug-{t}"), "ok");
226                    e.tools_requested = i;
227                    append_audit_entry_to(&base, &e).expect("append");
228                }
229            }));
230        }
231        for h in handles {
232            h.join().unwrap();
233        }
234        let entries = read_audit_entries_from(&base).unwrap();
235        assert_eq!(entries.len(), 40);
236    }
237
238    #[test]
239    fn new_audit_entry_produces_rfc3339_timestamp() {
240        let e = new_audit_entry(
241            "plug",
242            "prov",
243            "model",
244            true,
245            0,
246            false,
247            "ok",
248            None,
249        );
250        // Year-4-digits + 'T' separator + tz suffix ('Z' or '+'/'-' offset).
251        let ts = &e.timestamp;
252        assert!(ts.len() >= 20, "timestamp too short: {ts}");
253        assert!(
254            ts.chars().take(4).all(|c| c.is_ascii_digit()),
255            "expected 4-digit year: {ts}"
256        );
257        assert!(ts.contains('T'), "expected 'T' separator: {ts}");
258        assert!(
259            ts.ends_with('Z') || ts.contains('+') || ts[10..].contains('-'),
260            "expected timezone suffix: {ts}"
261        );
262        // chrono should be able to round-trip its own RFC3339 output.
263        chrono::DateTime::parse_from_rfc3339(ts)
264            .unwrap_or_else(|err| panic!("parse_from_rfc3339({ts}) failed: {err}"));
265    }
266
267    #[test]
268    fn round_trip_with_error_class_omitted_when_none() {
269        let dir = TempDir::new().unwrap();
270        let mut e = sample("plug", "ok");
271        e.error_class = None;
272        append_audit_entry_to(dir.path(), &e).unwrap();
273        let raw = std::fs::read_to_string(audit_file_path_for(dir.path())).unwrap();
274        assert!(
275            !raw.contains("error_class"),
276            "error_class should be skipped when None: {raw}"
277        );
278        let loaded = read_audit_entries_from(dir.path()).unwrap();
279        assert_eq!(loaded, vec![e]);
280    }
281}