agentd/runtime/audit.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **audit stream**: an append-only
3//! record of *who did what* — every A2A call, every principal-driven tool/command,
4//! config reloads, restores, store conflicts, and kills. Each event is
5//! `{ts, principal, role, action, target, outcome, request_id, trace, instance}`,
6//! emitted to the configured sinks: `log` (a closed-vocabulary `audit` log line)
7//! and/or `store` (a durable, append-only `Kind::Audit` record, ULID-keyed — never
8//! CAS'd, never listed, so it cannot be rewritten). Audit is security telemetry:
9//! it answers "why did the agent do that, and on whose authority?".
10
11use crate::config::v2::AuditSink;
12use crate::runtime::reactor::Runtime;
13use crate::state::{Kind, now_ms, ulid};
14use serde_json::{Value, json};
15
16/// One audit event to record.
17pub(crate) struct AuditEvent<'a> {
18 pub action: &'a str,
19 pub target: Value,
20 pub outcome: &'a str,
21 pub principal: Option<&'a str>,
22 pub role: Option<&'a str>,
23 pub request_id: Option<&'a str>,
24}
25
26impl Runtime {
27 /// Emit an audit event to the configured sinks. A no-op when no sink is
28 /// configured (`observability.audit.sink`). Cheap on the common path.
29 pub(crate) fn audit(&self, ev: AuditEvent<'_>) {
30 // Mirror onto the interface feed as operator-visible `audit` events
31 // when debug is on — independent of the sinks, which stay the
32 // durable/system record. The taskless interface READS are
33 // excluded: a display client polls them (debug.events at ~1 Hz), and
34 // mirroring their own audit back onto the feed would feed-loop the
35 // debug pane with its own plumbing. The durable sinks still record
36 // them.
37 #[cfg(feature = "a2a")]
38 if let Some(feed) = &self.a2a_feed
39 && feed.debug()
40 && !ev.action.ends_with(":interface.info")
41 && !ev.action.ends_with(":conversation.get")
42 && !ev.action.ends_with(":run.get")
43 && !ev.action.ends_with(":subagent.get")
44 && !ev.action.ends_with(":debug.events")
45 && !ev.action.ends_with(":pairing.code")
46 {
47 feed.push(
48 "audit",
49 super::a2a_server::FeedVis::Operator,
50 json!({
51 "ts": now_ms(),
52 "principal": ev.principal,
53 "role": ev.role,
54 "action": ev.action,
55 "target": ev.target,
56 "outcome": ev.outcome,
57 }),
58 );
59 }
60 let Some(sinks) = &self.settings.observability.audit.sink else {
61 return;
62 };
63 if sinks.is_empty() {
64 return;
65 }
66 let record = json!({
67 "ts": now_ms(),
68 "instance": self.instance,
69 "principal": ev.principal,
70 "role": ev.role,
71 "action": ev.action,
72 "target": ev.target,
73 "outcome": ev.outcome,
74 "request_id": ev.request_id,
75 "trace": self.trace_id,
76 });
77 if sinks.iter().any(|s| matches!(s, AuditSink::Log)) {
78 // A single closed-vocabulary `audit` event (never content-suppressed —
79 // an audit trail is metadata, not conversation content).
80 self.log.info("audit", record.clone());
81 }
82 if sinks.iter().any(|s| matches!(s, AuditSink::Stream))
83 && let Some(stream) = &self.settings.observability.audit.stream
84 {
85 // Queued, not appended: `audit` runs on `&self` from every
86 // authorization path, and the append needs the state owner. The
87 // tick drains it, which also puts these records behind the same
88 // pressure gate as every other admission.
89 crate::obs::log::tap_direct(stream, "audit", record.clone());
90 }
91 if sinks.iter().any(|s| matches!(s, AuditSink::Store)) {
92 // Append-only: a fresh ULID id per event (Kind::Audit is not indexed,
93 // so this never conflicts and is never overwritten).
94 let id = ulid::new();
95 if let Err(e) = self.durable.put(Kind::Audit, &id, record, None) {
96 // The store sink is best-effort telemetry — a failed audit write is
97 // logged but never fails the audited action.
98 self.log.warn(
99 "audit.store.fail",
100 json!({"action": ev.action, "err": e.to_string()}),
101 );
102 }
103 }
104 }
105
106 /// Audit an A2A request (the principal, the method/op, the outcome).
107 #[cfg(feature = "a2a")]
108 pub(crate) fn audit_a2a(
109 &self,
110 method: &str,
111 op: Option<&str>,
112 principal: &crate::a2a::Principal,
113 outcome: &str,
114 target: Value,
115 request_id: Option<&str>,
116 ) {
117 let action = match op {
118 Some(o) => format!("a2a.{method}:{o}"),
119 None => format!("a2a.{method}"),
120 };
121 let role = format!("{:?}", principal.role).to_lowercase();
122 self.audit(AuditEvent {
123 action: &action,
124 target,
125 outcome,
126 principal: Some(&principal.id),
127 role: Some(&role),
128 request_id,
129 });
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 // The emitter is exercised end-to-end by `runtime_v2_audit_e2e` (a real
136 // daemon with `observability.audit.sink: [log]`); a pure-unit test would only
137 // restate the JSON shape. The shape is asserted there against the log line.
138}