Skip to main content

agent_sdk_tools/
audit.rs

1//! Authoritative tool audit sink.
2//!
3//! The [`ToolAuditSink`] trait is the server-side audit surface for
4//! tool lifecycle outcomes. A single sink receives one
5//! [`ToolAuditRecord`] per lifecycle transition — blocked,
6//! `requires-confirmation`, cached, replayed, invalidated, completed,
7//! and `persistence-failed` — and is free to forward them to any
8//! durable audit backend.
9//!
10//! # Why this exists
11//!
12//! Before Phase 1.6 the only audit surface on the authoritative path
13//! was [`AgentHooks::post_tool_use`](crate::hooks::AgentHooks::post_tool_use),
14//! which fires exactly once, only after successful tool completion. A
15//! server that needs to explain *why* a tool never produced a result —
16//! or why a persisted outcome diverged from the in-memory result — has
17//! no way to do so from `post_tool_use` alone.
18//!
19//! `ToolAuditSink` replaces that dependency. The turn loop now emits
20//! an audit record at every lifecycle transition on **both** the inline
21//! local-mode path and the externalised tool-runtime path.
22//!
23//! # Default sink
24//!
25//! The SDK ships [`NoopAuditSink`] for callers that don't need durable
26//! audit. Servers should swap it for a sink that writes to their
27//! audit-log backend.
28//!
29//! # Trait shape
30//!
31//! The record shape lives in [`agent_sdk_foundation::audit`] so it stays
32//! data-only; the async trait lives here so `agent-sdk-foundation` does not
33//! need to depend on `async-trait`.
34
35use agent_sdk_foundation::audit::ToolAuditRecord;
36use async_trait::async_trait;
37use std::sync::Arc;
38
39/// Async sink that receives one [`ToolAuditRecord`] per tool-call
40/// lifecycle transition.
41///
42/// Sinks **must** be cheap: the turn loop awaits `record` on the hot
43/// path. If the durable backend is slow, the implementation should
44/// buffer and flush asynchronously rather than blocking the sink.
45///
46/// The sink must never panic; persistence failures should be logged
47/// locally and a `persistence_failed` record fed back through the
48/// normal path.
49#[async_trait]
50pub trait ToolAuditSink: Send + Sync + 'static {
51    /// Record a single lifecycle event for a tool call.
52    ///
53    /// Called from the authoritative turn loop at every lifecycle
54    /// transition. Implementations should be idempotent keyed on
55    /// `(record.tool_call_id, record.outcome_kind())` — the same logical
56    /// transition may arrive more than once if the loop retries after
57    /// a transient failure.
58    async fn record(&self, record: ToolAuditRecord);
59}
60
61/// Default sink that discards every record.
62///
63/// Useful as a placeholder before a server wires in its own audit
64/// backend, and as a zero-overhead default for local/CLI usage.
65#[derive(Clone, Copy, Debug, Default)]
66pub struct NoopAuditSink;
67
68#[async_trait]
69impl ToolAuditSink for NoopAuditSink {
70    async fn record(&self, _record: ToolAuditRecord) {
71        // Intentionally no-op.
72    }
73}
74
75/// Blanket impl so `Arc<S>` is itself a sink — lets callers share one
76/// backend across clone boundaries without wrapping.
77#[async_trait]
78impl<S: ToolAuditSink + ?Sized> ToolAuditSink for Arc<S> {
79    async fn record(&self, record: ToolAuditRecord) {
80        (**self).record(record).await;
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use agent_sdk_foundation::audit::{AuditProvenance, ToolAuditOutcome, ToolAuditRecord};
88    use agent_sdk_foundation::types::{ToolResult, ToolTier};
89    use tokio::sync::Mutex;
90
91    /// A test sink that captures every record it receives.
92    ///
93    /// Uses `tokio::sync::Mutex` so `.lock().await` is infallible and the
94    /// sink never needs a panic path — per `CLAUDE.md` no `.unwrap()` is
95    /// allowed even in tests.
96    #[derive(Default)]
97    pub struct VecSink {
98        pub records: Mutex<Vec<ToolAuditRecord>>,
99    }
100
101    #[async_trait]
102    impl ToolAuditSink for VecSink {
103        async fn record(&self, record: ToolAuditRecord) {
104            self.records.lock().await.push(record);
105        }
106    }
107
108    fn sample_record(outcome: ToolAuditOutcome) -> ToolAuditRecord {
109        ToolAuditRecord::new(agent_sdk_foundation::audit::ToolAuditRecordParams {
110            tool_call_id: "call_x".into(),
111            tool_name: "tool_x".into(),
112            display_name: "Tool X".into(),
113            tier: ToolTier::Observe,
114            requested_input: serde_json::json!({}),
115            effective_input: serde_json::json!({}),
116            turn: 1,
117            provenance: AuditProvenance::new("anthropic", "claude-sonnet-4-5-20250929"),
118            outcome,
119        })
120    }
121
122    #[tokio::test]
123    async fn noop_sink_accepts_all_variants_without_panicking() {
124        let sink = NoopAuditSink;
125        sink.record(sample_record(ToolAuditOutcome::Blocked {
126            reason: "nope".into(),
127        }))
128        .await;
129        sink.record(sample_record(ToolAuditOutcome::RequiresConfirmation {
130            description: "ok?".into(),
131            listen_context: None,
132        }))
133        .await;
134        sink.record(sample_record(ToolAuditOutcome::Completed {
135            result: ToolResult::success("ok"),
136        }))
137        .await;
138    }
139
140    #[tokio::test]
141    async fn arc_wrapped_sink_forwards_records() {
142        let inner = Arc::new(VecSink::default());
143        let sink: Arc<dyn ToolAuditSink> = inner.clone();
144
145        sink.record(sample_record(ToolAuditOutcome::Cached {
146            result: ToolResult::success("cached"),
147        }))
148        .await;
149
150        let kinds: Vec<&'static str> = inner
151            .records
152            .lock()
153            .await
154            .iter()
155            .map(ToolAuditRecord::outcome_kind)
156            .collect();
157        assert_eq!(kinds, vec!["cached"]);
158    }
159
160    #[tokio::test]
161    async fn sink_captures_every_lifecycle_variant() {
162        let sink = Arc::new(VecSink::default());
163
164        for outcome in [
165            ToolAuditOutcome::Blocked { reason: "r".into() },
166            ToolAuditOutcome::RequiresConfirmation {
167                description: "d".into(),
168                listen_context: None,
169            },
170            ToolAuditOutcome::Cached {
171                result: ToolResult::success("c"),
172            },
173            ToolAuditOutcome::Replayed {
174                result: ToolResult::success("r"),
175            },
176            ToolAuditOutcome::Invalidated { reason: "i".into() },
177            ToolAuditOutcome::Completed {
178                result: ToolResult::success("ok"),
179            },
180            ToolAuditOutcome::PersistenceFailed {
181                result: None,
182                error: "boom".into(),
183            },
184        ] {
185            sink.record(sample_record(outcome)).await;
186        }
187
188        let kinds: Vec<&'static str> = sink
189            .records
190            .lock()
191            .await
192            .iter()
193            .map(ToolAuditRecord::outcome_kind)
194            .collect();
195        assert_eq!(
196            kinds,
197            vec![
198                "blocked",
199                "requires_confirmation",
200                "cached",
201                "replayed",
202                "invalidated",
203                "completed",
204                "persistence_failed",
205            ],
206        );
207    }
208}