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(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// ── Tool-output intercept audit (HookResult::Replace) ───────────────────────
109
110/// Audit record for an extension rewriting a tool's output via
111/// `HookResult::Replace`. Captures WHO rewrote WHICH tool's output and WHEN,
112/// plus sizes — never the content (privacy), matching the provider-audit policy.
113#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
114pub struct InterceptAuditEntry {
115    /// RFC3339 UTC timestamp.
116    pub timestamp: String,
117    pub plugin_id: String,
118    /// Hook that fired (e.g. "after_tool_call").
119    pub hook: String,
120    /// Tool whose output was rewritten, when tool-specific.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub tool_name: Option<String>,
123    /// Action taken (currently always "replace").
124    pub action: String,
125    /// Length (bytes) of the output the extension observed. For large outputs
126    /// this is the 32 KB event preview, not the full original.
127    pub observed_len: usize,
128    /// Length (bytes) of the replacement the extension returned.
129    pub replacement_len: usize,
130}
131
132/// Path to the intercept audit file (`$SYNAPS_BASE_DIR/extensions/intercept.jsonl`).
133pub 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
141/// Record that an extension replaced a tool's output. Best-effort and
142/// metadata-only — auditing must never break or alter the tool flow, so
143/// callers typically ignore the result.
144pub 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/// Test-build tripwire (#186): a unit test that reaches an ambient-path audit
164/// writer without `crate::test_env::BaseDirGuard` would append to the
165/// developer's REAL `~/.synaps-cli/extensions/…` (this happened: `first`,
166/// `mb-replacer`, … entries in a real intercept.jsonl). Fail loudly instead,
167/// before anything is written. Compiled out of non-test builds.
168#[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
214/// Read all entries (one per line). Missing file → empty Vec. Malformed
215/// lines are skipped with a `tracing::warn!`.
216pub fn read_audit_entries() -> Result<Vec<ProviderAuditEntry>, String> {
217    read_audit_entries_from(&crate::config::base_dir())
218}
219
220/// Read entries under an explicit base dir.
221pub(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
256/// Read only the last `n` entries from the audit log.  Reads the whole file
257/// but collects lines into a fixed-capacity ring so only the tail N lines are
258/// ever parsed by serde — O(file_size) bytes read but O(N) deserialisation.
259/// Missing file → empty Vec.  Malformed tail lines are skipped with a warn.
260pub fn read_audit_entries_tail(n: usize) -> Result<Vec<ProviderAuditEntry>, String> {
261    read_audit_entries_tail_from(&crate::config::base_dir(), n)
262}
263
264/// `read_audit_entries_tail` under an explicit base dir (test helper).
265pub(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    // Collect the last N non-empty lines without parsing them yet — avoids
285    // deserialising the entire history when only the tail is needed.
286    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        // Privacy: the record carries sizes, never tool content.
390        assert!(
391            !raw.contains("tool_output"),
392            "audit line must not carry content"
393        );
394    }
395
396    /// #186 tripwire: an un-isolated test write is rejected before touching
397    /// the filesystem (so it can never land in the real home).
398    #[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        // Guard restores the caller's SYNAPS_BASE_DIR on unwind.
403        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        // Inject a malformed line.
415        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        // Year-4-digits + 'T' separator + tz suffix ('Z' or '+'/'-' offset).
451        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 should be able to round-trip its own RFC3339 output.
463        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}