lean_ctx/core/compliance_report/
aggregate.rs1use std::collections::BTreeMap;
17
18use chrono::{DateTime, FixedOffset};
19
20use crate::core::audit_trail::{AuditEntry, AuditEventType};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Aggregation {
25 pub entries: usize,
27 pub tool_calls: usize,
29 pub blocked: usize,
31 pub redacted: usize,
33 pub other_security: usize,
35 pub by_event: Vec<(String, usize)>,
37 pub by_tool_blocked: Vec<(String, usize)>,
39 pub anchor_prev_hash: String,
41 pub head_hash: String,
43}
44
45const MAX_TOOL_ROWS: usize = 12;
47
48pub 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
68pub 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
88pub 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 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
156fn 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 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}"); 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}