Skip to main content

lean_ctx/core/compliance_report/
aggregate.rs

1//! Audit-trail aggregation over a date range (GL #677).
2//!
3//! Reads the append-only audit chain (`<data_dir>/audit/trail.jsonl`) and folds
4//! the entries whose `timestamp` falls inside `[from, to]` into the
5//! privacy-preserving counts a compliance report needs: how many agent actions
6//! were **blocked** (`ToolDenied`) and how much content was **redacted**
7//! (`SecretDetected`), plus the chain anchor/head that bind those counts to the
8//! exact append-only segment that produced them.
9//!
10//! Mirrors the streaming, multi-object-per-line tolerant parse of
11//! [`crate::core::evidence_bundle`] (concurrent appends have historically
12//! produced two back-to-back JSON objects on one line), but — unlike an
13//! evidence bundle — an **empty** window is a valid, healthy result (a quiet
14//! period with zero violations), never an error.
15
16use std::collections::BTreeMap;
17
18use chrono::{DateTime, FixedOffset};
19
20use crate::core::audit_trail::{AuditEntry, AuditEventType};
21
22/// Folded, privacy-preserving view of one audit-trail segment.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Aggregation {
25    /// Entries whose timestamp fell inside the window.
26    pub entries: usize,
27    /// `ToolCall` events (the denominator for an enforcement rate).
28    pub tool_calls: usize,
29    /// `ToolDenied` events — agent actions blocked by role/policy/egress.
30    pub blocked: usize,
31    /// `SecretDetected` events — outputs where redaction fired.
32    pub redacted: usize,
33    /// Other non-`ToolCall` security events (path-jail, budget, rate-limit, …).
34    pub other_security: usize,
35    /// `(event_label, count)` for every event type seen, sorted by label.
36    pub by_event: Vec<(String, usize)>,
37    /// `(tool, blocked_count)` for blocked actions, top rows by count.
38    pub by_tool_blocked: Vec<(String, usize)>,
39    /// `prev_hash` of the first in-window entry (`genesis` when empty).
40    pub anchor_prev_hash: String,
41    /// `entry_hash` of the last in-window entry (`""` when empty).
42    pub head_hash: String,
43}
44
45/// Cap on `by_tool_blocked` rows embedded in a report (keeps it bounded).
46const MAX_TOOL_ROWS: usize = 12;
47
48/// Stable snake_case label for an event type (matches the on-disk encoding,
49/// without depending on serde formatting on the hot path).
50pub fn event_label(ev: &AuditEventType) -> &'static str {
51    match ev {
52        AuditEventType::ToolCall => "tool_call",
53        AuditEventType::ToolDenied => "tool_denied",
54        AuditEventType::PathJailViolation => "path_jail_violation",
55        AuditEventType::BudgetExceeded => "budget_exceeded",
56        AuditEventType::CrossProjectAccess => "cross_project_access",
57        AuditEventType::RateLimited => "rate_limited",
58        AuditEventType::SecurityViolation => "security_violation",
59        AuditEventType::RoleChanged => "role_changed",
60        AuditEventType::SecretDetected => "secret_detected",
61        AuditEventType::AgentRegistered => "agent_registered",
62        AuditEventType::AgentSuspended => "agent_suspended",
63        AuditEventType::AgentResumed => "agent_resumed",
64        AuditEventType::AgentDecommissioned => "agent_decommissioned",
65    }
66}
67
68/// Aggregate the on-disk audit trail over `[from, to]` (both inclusive).
69///
70/// A missing trail file yields an empty aggregation (no audit activity yet),
71/// not an error — a fresh install can still produce a (zero-violation) report.
72pub fn aggregate(
73    from: DateTime<FixedOffset>,
74    to: DateTime<FixedOffset>,
75) -> Result<Aggregation, String> {
76    let trail_path = crate::core::data_dir::lean_ctx_data_dir()
77        .map_err(|e| format!("data dir: {e}"))?
78        .join("audit")
79        .join("trail.jsonl");
80    let raw = match std::fs::read_to_string(&trail_path) {
81        Ok(s) => s,
82        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(empty()),
83        Err(e) => return Err(format!("read {}: {e}", trail_path.display())),
84    };
85    aggregate_str(&raw, from, to)
86}
87
88/// Pure aggregation over raw JSONL — testable without touching the data dir.
89pub fn aggregate_str(
90    raw: &str,
91    from: DateTime<FixedOffset>,
92    to: DateTime<FixedOffset>,
93) -> Result<Aggregation, String> {
94    let mut agg = empty();
95    let mut events: BTreeMap<String, usize> = BTreeMap::new();
96    let mut tools: BTreeMap<String, usize> = BTreeMap::new();
97    let mut anchor: Option<String> = None;
98
99    for line in raw.lines() {
100        // Tolerate concurrent-append history (`…}{…`) with a streaming parse.
101        for value in serde_json::Deserializer::from_str(line)
102            .into_iter::<serde_json::Value>()
103            .flatten()
104        {
105            let Ok(entry) = serde_json::from_value::<AuditEntry>(value) else {
106                continue;
107            };
108            let Ok(ts) = DateTime::parse_from_rfc3339(&entry.timestamp) else {
109                continue;
110            };
111            if ts < from || ts > to {
112                continue;
113            }
114
115            if anchor.is_none() {
116                anchor = Some(entry.prev_hash.clone());
117            }
118            agg.head_hash.clone_from(&entry.entry_hash);
119            agg.entries += 1;
120            *events
121                .entry(event_label(&entry.event_type).to_string())
122                .or_default() += 1;
123
124            match entry.event_type {
125                AuditEventType::ToolCall => agg.tool_calls += 1,
126                AuditEventType::ToolDenied => {
127                    agg.blocked += 1;
128                    *tools.entry(entry.tool.clone()).or_default() += 1;
129                }
130                AuditEventType::SecretDetected => agg.redacted += 1,
131                _ => agg.other_security += 1,
132            }
133        }
134    }
135
136    agg.anchor_prev_hash = anchor.unwrap_or_else(|| "genesis".to_string());
137    agg.by_event = events.into_iter().collect();
138    agg.by_tool_blocked = top_rows(tools);
139    Ok(agg)
140}
141
142fn empty() -> Aggregation {
143    Aggregation {
144        entries: 0,
145        tool_calls: 0,
146        blocked: 0,
147        redacted: 0,
148        other_security: 0,
149        by_event: Vec::new(),
150        by_tool_blocked: Vec::new(),
151        anchor_prev_hash: "genesis".to_string(),
152        head_hash: String::new(),
153    }
154}
155
156/// Sort `(tool, count)` by count desc then tool asc (stable, deterministic),
157/// capped at [`MAX_TOOL_ROWS`].
158fn top_rows(map: BTreeMap<String, usize>) -> Vec<(String, usize)> {
159    let mut rows: Vec<(String, usize)> = map.into_iter().collect();
160    rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
161    rows.truncate(MAX_TOOL_ROWS);
162    rows
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn ts(s: &str) -> DateTime<FixedOffset> {
170        DateTime::parse_from_rfc3339(s).unwrap()
171    }
172
173    /// One audit line with a fixed (already-broken-out) shape. Hashes are
174    /// arbitrary here — aggregation never recomputes the chain, it only reads
175    /// the recorded `prev_hash`/`entry_hash`.
176    fn line(ts: &str, event: &str, tool: &str, prev: &str, hash: &str) -> String {
177        format!(
178            r#"{{"timestamp":"{ts}","agent_id":"a","tool":"{tool}","action":null,"input_hash":"x","output_tokens":0,"role":"r","event_type":"{event}","prev_hash":"{prev}","entry_hash":"{hash}"}}"#
179        )
180    }
181
182    #[test]
183    fn counts_blocked_and_redacted_in_window() {
184        let raw = [
185            line(
186                "2026-06-01T10:00:00+00:00",
187                "tool_call",
188                "ctx_read",
189                "genesis",
190                "h1",
191            ),
192            line(
193                "2026-06-01T11:00:00+00:00",
194                "tool_denied",
195                "ctx_url_read",
196                "h1",
197                "h2",
198            ),
199            line(
200                "2026-06-01T12:00:00+00:00",
201                "secret_detected",
202                "ctx_read",
203                "h2",
204                "h3",
205            ),
206            line(
207                "2026-06-01T13:00:00+00:00",
208                "tool_denied",
209                "ctx_url_read",
210                "h3",
211                "h4",
212            ),
213        ]
214        .join("\n");
215        let agg = aggregate_str(
216            &raw,
217            ts("2026-06-01T00:00:00+00:00"),
218            ts("2026-06-02T00:00:00+00:00"),
219        )
220        .unwrap();
221        assert_eq!(agg.entries, 4);
222        assert_eq!(agg.blocked, 2);
223        assert_eq!(agg.redacted, 1);
224        assert_eq!(agg.tool_calls, 1);
225        assert_eq!(agg.anchor_prev_hash, "genesis");
226        assert_eq!(agg.head_hash, "h4");
227        assert_eq!(agg.by_tool_blocked, vec![("ctx_url_read".to_string(), 2)]);
228    }
229
230    #[test]
231    fn excludes_entries_outside_window() {
232        let raw = [
233            line(
234                "2026-05-01T10:00:00+00:00",
235                "tool_denied",
236                "ctx_url_read",
237                "genesis",
238                "h1",
239            ),
240            line(
241                "2026-06-15T10:00:00+00:00",
242                "tool_denied",
243                "ctx_url_read",
244                "h1",
245                "h2",
246            ),
247        ]
248        .join("\n");
249        let agg = aggregate_str(
250            &raw,
251            ts("2026-06-01T00:00:00+00:00"),
252            ts("2026-07-01T00:00:00+00:00"),
253        )
254        .unwrap();
255        assert_eq!(agg.entries, 1);
256        assert_eq!(agg.blocked, 1);
257        assert_eq!(agg.anchor_prev_hash, "h1");
258        assert_eq!(agg.head_hash, "h2");
259    }
260
261    #[test]
262    fn empty_window_is_ok_not_error() {
263        let raw = line(
264            "2026-01-01T10:00:00+00:00",
265            "tool_denied",
266            "ctx_url_read",
267            "genesis",
268            "h1",
269        );
270        let agg = aggregate_str(
271            &raw,
272            ts("2026-06-01T00:00:00+00:00"),
273            ts("2026-07-01T00:00:00+00:00"),
274        )
275        .unwrap();
276        assert_eq!(agg, super::empty());
277    }
278
279    #[test]
280    fn tolerates_two_objects_on_one_line() {
281        let a = line(
282            "2026-06-01T10:00:00+00:00",
283            "tool_denied",
284            "ctx_url_read",
285            "genesis",
286            "h1",
287        );
288        let b = line(
289            "2026-06-01T10:00:01+00:00",
290            "secret_detected",
291            "ctx_read",
292            "h1",
293            "h2",
294        );
295        let raw = format!("{a}{b}"); // concatenated, no newline
296        let agg = aggregate_str(
297            &raw,
298            ts("2026-06-01T00:00:00+00:00"),
299            ts("2026-06-02T00:00:00+00:00"),
300        )
301        .unwrap();
302        assert_eq!(agg.entries, 2);
303        assert_eq!(agg.blocked, 1);
304        assert_eq!(agg.redacted, 1);
305    }
306}