Skip to main content

agent_engine/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// ── Tool-output intercept audit (HookResult::Replace) ───────────────────────
112
113/// Audit record for an extension rewriting a tool's output via
114/// `HookResult::Replace`. Captures WHO rewrote WHICH tool's output and WHEN,
115/// plus sizes — never the content (privacy), matching the provider-audit policy.
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
117pub struct InterceptAuditEntry {
118    /// RFC3339 UTC timestamp.
119    pub timestamp: String,
120    pub plugin_id: String,
121    /// Hook that fired (e.g. "after_tool_call").
122    pub hook: String,
123    /// Tool whose output was rewritten, when tool-specific.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub tool_name: Option<String>,
126    /// Action taken (currently always "replace").
127    pub action: String,
128    /// Length (bytes) of the output the extension observed. For large outputs
129    /// this is the 32 KB event preview, not the full original.
130    pub observed_len: usize,
131    /// Length (bytes) of the replacement the extension returned.
132    pub replacement_len: usize,
133}
134
135/// Path to the intercept audit file (`$SYNAPS_BASE_DIR/extensions/intercept.jsonl`).
136pub 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
144/// Record that an extension replaced a tool's output. Best-effort and
145/// metadata-only — auditing must never break or alter the tool flow, so
146/// callers typically ignore the result.
147pub 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
200/// Read all entries (one per line). Missing file → empty Vec. Malformed
201/// lines are skipped with a `tracing::warn!`.
202pub fn read_audit_entries() -> Result<Vec<ProviderAuditEntry>, String> {
203    read_audit_entries_from(&crate::config::base_dir())
204}
205
206/// Read entries under an explicit base dir.
207pub(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
244/// Read only the last `n` entries from the audit log.  Reads the whole file
245/// but collects lines into a fixed-capacity ring so only the tail N lines are
246/// ever parsed by serde — O(file_size) bytes read but O(N) deserialisation.
247/// Missing file → empty Vec.  Malformed tail lines are skipped with a warn.
248pub fn read_audit_entries_tail(n: usize) -> Result<Vec<ProviderAuditEntry>, String> {
249    read_audit_entries_tail_from(&crate::config::base_dir(), n)
250}
251
252/// `read_audit_entries_tail` under an explicit base dir (test helper).
253pub(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    // Collect the last N non-empty lines without parsing them yet — avoids
273    // deserialising the entire history when only the tail is needed.
274    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        // Privacy: the record carries sizes, never tool content.
378        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        // Inject a malformed line.
388        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        // Year-4-digits + 'T' separator + tz suffix ('Z' or '+'/'-' offset).
433        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 should be able to round-trip its own RFC3339 output.
445        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}