Skip to main content

agentstatedeveloper_core/
audit.rs

1//! Audit-log event stream — OSS data format.
2//!
3//! Every user-visible mutation (ledger append/approve/reject/withdraw/
4//! supersede, effect_declare) and every policy decision is emitted as
5//! an [`AuditEvent`] to a configurable [`AuditSink`]. The OSS binary
6//! ships only [`NullSink`] (no-op) — the hash-chained file sink with
7//! tamper-evident verification lives in the commercial
8//! `agentstatedeveloper-audit-pro` crate (Enterprise tier).
9//!
10//! The schema here is shared between OSS and Pro: an OSS `asd` binary
11//! can read a log written by `asd-pro` (forward-compatible degrade),
12//! it just can't write chain-signed events or verify the chain.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::error::Result;
19use std::path::Path;
20
21/// Prefix for blake3-based event hashes. Versioned so we can change
22/// the algorithm later without ambiguity. Shared between OSS readers
23/// and Pro writers.
24pub const HASH_PREFIX: &str = "b3:";
25
26// ---------------------------------------------------------------------------
27// Event vocabulary
28// ---------------------------------------------------------------------------
29
30/// Canonical event type strings. Stable — downstream SIEM rules key on these.
31pub mod event_types {
32    pub const LEDGER_APPEND: &str = "ledger.append";
33    pub const LEDGER_APPROVE: &str = "ledger.approve";
34    pub const LEDGER_REJECT: &str = "ledger.reject";
35    pub const LEDGER_WITHDRAW: &str = "ledger.withdraw";
36    pub const LEDGER_SUPERSEDE: &str = "ledger.supersede";
37    pub const LEDGER_REBIND: &str = "ledger.rebind";
38    pub const EFFECT_DECLARE: &str = "effect.declare";
39    pub const INDEX_RUN: &str = "index.run";
40}
41
42/// One audit entry. Flat, JSON-serializable. Shared data format —
43/// `event_hash` / `prev_event_hash` are empty strings / None when the
44/// OSS binary emits (via `NullSink`) and filled in by the Pro sink.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AuditEvent {
47    /// `evt_<uuid-simple>` — stable id for dedup / ordering.
48    pub event_id: String,
49    /// One of [`event_types`], or a custom extension string.
50    pub event_type: String,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub subject_id: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub secondary_id: Option<String>,
55    pub actor_id: String,
56    pub actor_kind: String,
57    pub timestamp: DateTime<Utc>,
58    pub outcome: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub matched_policy: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub reason: Option<String>,
63    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
64    pub payload: serde_json::Value,
65    /// Hash of the previous chained event, or `None` for chain start.
66    /// Pro writers populate this; OSS writers leave it `None`.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub prev_event_hash: Option<String>,
69    /// Hash of THIS event's canonical bytes. Empty string means the
70    /// event is unsigned — Pro writers fill it in, OSS writers don't.
71    #[serde(default)]
72    pub event_hash: String,
73}
74
75impl AuditEvent {
76    pub fn new(
77        event_type: impl Into<String>,
78        actor_id: impl Into<String>,
79        actor_kind: impl Into<String>,
80        outcome: impl Into<String>,
81    ) -> Self {
82        Self {
83            event_id: format!("evt_{}", Uuid::new_v4().simple()),
84            event_type: event_type.into(),
85            subject_id: None,
86            secondary_id: None,
87            actor_id: actor_id.into(),
88            actor_kind: actor_kind.into(),
89            timestamp: Utc::now(),
90            outcome: outcome.into(),
91            matched_policy: None,
92            reason: None,
93            payload: serde_json::Value::Null,
94            prev_event_hash: None,
95            event_hash: String::new(),
96        }
97    }
98
99    /// True iff the event carries a chain hash. OSS readers can use
100    /// this to distinguish signed (Pro-written) events from unsigned
101    /// ones, even though OSS can't verify the chain.
102    pub fn is_signed(&self) -> bool {
103        !self.event_hash.is_empty()
104    }
105
106    pub fn with_subject(mut self, subject_id: impl Into<String>) -> Self {
107        self.subject_id = Some(subject_id.into());
108        self
109    }
110
111    pub fn with_secondary(mut self, secondary_id: impl Into<String>) -> Self {
112        self.secondary_id = Some(secondary_id.into());
113        self
114    }
115
116    pub fn with_matched_policy(mut self, matched_policy: Option<String>) -> Self {
117        self.matched_policy = matched_policy;
118        self
119    }
120
121    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
122        self.reason = Some(reason.into());
123        self
124    }
125
126    pub fn with_payload(mut self, payload: serde_json::Value) -> Self {
127        self.payload = payload;
128        self
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Sink trait + OSS default
134// ---------------------------------------------------------------------------
135
136pub trait AuditSink: Send + Sync {
137    fn emit(&self, event: &AuditEvent) -> Result<()>;
138
139    fn emit_all(&self, events: &[AuditEvent]) -> Result<()> {
140        for e in events {
141            self.emit(e)?;
142        }
143        Ok(())
144    }
145}
146
147/// Fire-and-forget audit emit. Logs sink failures to stderr but never
148/// propagates — audit issues must never block the user's operation.
149pub fn emit_audit(sink: &dyn AuditSink, event: AuditEvent) {
150    if let Err(e) = sink.emit(&event) {
151        eprintln!("warning: audit emit failed: {e}");
152    }
153}
154
155/// Swallows every event. The OSS default — tamper-evident file-backed
156/// audit is a commercial feature (see `agentstatedeveloper-audit-pro`).
157pub struct NullSink;
158
159impl AuditSink for NullSink {
160    fn emit(&self, _event: &AuditEvent) -> Result<()> {
161        Ok(())
162    }
163}
164
165// ---------------------------------------------------------------------------
166// JSONL reader — used by OSS `audit tail` AND by Pro `audit verify`
167// ---------------------------------------------------------------------------
168
169/// Read back events from a JSONL file. Shared by OSS read-only
170/// consumers (`asd audit tail`) and the Pro `verify_chain` code path.
171pub fn read_jsonl(path: &Path) -> Result<Vec<AuditEvent>> {
172    if !path.exists() {
173        return Ok(Vec::new());
174    }
175    let raw = std::fs::read_to_string(path)?;
176    let mut out = Vec::new();
177    for line in raw.lines() {
178        let line = line.trim();
179        if line.is_empty() {
180            continue;
181        }
182        let event: AuditEvent = serde_json::from_str(line)?;
183        out.push(event);
184    }
185    Ok(out)
186}
187
188// ---------------------------------------------------------------------------
189// Tests
190// ---------------------------------------------------------------------------
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn event_serialization_round_trip() {
198        let e = AuditEvent::new(
199            event_types::LEDGER_APPEND,
200            "alice",
201            "human",
202            "awaiting-approval",
203        )
204        .with_subject("led_abc123")
205        .with_secondary("sym_xyz")
206        .with_matched_policy(Some("/policies/code/hazard@1".into()))
207        .with_reason("requires human approval")
208        .with_payload(serde_json::json!({"kind": "hazard", "qname": "payments.x"}));
209
210        let json = serde_json::to_string(&e).unwrap();
211        let parsed: AuditEvent = serde_json::from_str(&json).unwrap();
212        assert_eq!(parsed.event_id, e.event_id);
213        assert_eq!(parsed.event_type, event_types::LEDGER_APPEND);
214        assert_eq!(parsed.actor_id, "alice");
215    }
216
217    #[test]
218    fn null_sink_accepts_everything() {
219        let sink = NullSink;
220        let e = AuditEvent::new("x", "y", "z", "success");
221        sink.emit(&e).unwrap();
222    }
223
224    #[test]
225    fn read_empty_path_returns_empty() {
226        let path = std::path::PathBuf::from("/tmp/does-not-exist-asd-audit-test.jsonl");
227        if path.exists() {
228            std::fs::remove_file(&path).ok();
229        }
230        let events = read_jsonl(&path).unwrap();
231        assert!(events.is_empty());
232    }
233}