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}