1use std::collections::BTreeMap;
16use std::io::Write;
17use std::sync::Arc;
18
19use anyhow::Result;
20use clap::{Args, ValueEnum};
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use systemprompt_ai::AiSafetyFindingRepository;
24use systemprompt_security::authz::list_governance_warnings;
25
26use crate::CliConfig;
27use crate::commands::infrastructure::logs::duration::parse_since;
28use crate::shared::{CommandOutput, render_result};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "lowercase")]
33pub enum GroupBy {
34 Policy,
35 Tool,
36 User,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
40pub enum ReportFormat {
41 Table,
42 Csv,
43}
44
45#[derive(Debug, Args)]
46pub struct ReportArgs {
47 #[arg(
48 long,
49 default_value = "24h",
50 help = "Window to report over (e.g. '1h', '24h', '7d') or a datetime"
51 )]
52 pub since: String,
53
54 #[arg(
55 long,
56 value_enum,
57 default_value = "policy",
58 help = "Dimension to group governance warnings by"
59 )]
60 pub group_by: GroupBy,
61
62 #[arg(
63 long,
64 value_enum,
65 default_value = "table",
66 help = "Render as a table, or emit CSV on stdout for a spreadsheet"
67 )]
68 pub format: ReportFormat,
69
70 #[arg(long, default_value = "50", help = "Maximum rows per section")]
71 pub limit: i64,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct WarningGroupRow {
76 pub group: String,
77 pub warnings: i64,
78 pub tools: i64,
79 pub users: i64,
80 pub last_seen: String,
81 pub example_reason: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct SafetyFindingGroupRow {
86 pub category: String,
87 pub scanner: String,
88 pub severity: String,
89 pub phase: String,
90 pub findings: i64,
91 pub blocked: i64,
92 pub last_seen: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
96pub struct GovernanceReportOutput {
97 pub since: String,
98 pub group_by: GroupBy,
99 pub total_warnings: i64,
100 pub warnings: Vec<WarningGroupRow>,
101 pub total_findings: i64,
102 pub total_blocked_findings: i64,
103 pub safety_findings: Vec<SafetyFindingGroupRow>,
104}
105
106crate::define_pool_command!(ReportArgs => (), with_config);
107
108#[derive(Default)]
112struct GroupAccumulator {
113 warnings: i64,
114 tools: std::collections::BTreeSet<String>,
115 users: std::collections::BTreeSet<String>,
116 last_seen: Option<chrono::DateTime<chrono::Utc>>,
117 example_reason: String,
118}
119
120async fn gather(args: &ReportArgs, pool: &Arc<sqlx::PgPool>) -> Result<GovernanceReportOutput> {
121 let since = parse_since(Some(&args.since))?;
122
123 let rows = list_governance_warnings(pool.as_ref(), since, args.limit.max(1) * 20).await?;
124 let total_warnings = rows.iter().map(|r| r.count).sum();
125
126 let mut groups: BTreeMap<String, GroupAccumulator> = BTreeMap::new();
127 for row in rows {
128 let key = match args.group_by {
129 GroupBy::Policy => row.policy.clone(),
130 GroupBy::Tool => row.tool_name.clone(),
131 GroupBy::User => row.user_id.clone(),
132 };
133 let acc = groups.entry(key).or_default();
134 acc.warnings += row.count;
135 acc.tools.insert(row.tool_name);
136 acc.users.insert(row.user_id);
137 if acc.last_seen.is_none_or(|seen| seen < row.last_seen) {
138 acc.last_seen = Some(row.last_seen);
139 acc.example_reason = row.example_reason;
140 }
141 }
142
143 let mut warnings: Vec<WarningGroupRow> = groups
144 .into_iter()
145 .map(|(group, acc)| WarningGroupRow {
146 group,
147 warnings: acc.warnings,
148 tools: acc.tools.len() as i64,
149 users: acc.users.len() as i64,
150 last_seen: acc
151 .last_seen
152 .map_or_else(String::new, |t| t.format("%Y-%m-%d %H:%M:%S").to_string()),
153 example_reason: truncate(&acc.example_reason, 120),
154 })
155 .collect();
156 warnings.sort_by(|a, b| {
157 b.warnings
158 .cmp(&a.warnings)
159 .then_with(|| a.group.cmp(&b.group))
160 });
161 warnings.truncate(usize::try_from(args.limit.max(0)).unwrap_or(usize::MAX));
162
163 let findings_repo = AiSafetyFindingRepository::from_pool(Arc::clone(pool));
164 let finding_rows = findings_repo.list_rollup(since, args.limit.max(1)).await?;
165 let total_findings = finding_rows.iter().map(|r| r.count).sum();
166 let total_blocked_findings = finding_rows.iter().map(|r| r.blocked_count).sum();
167 let safety_findings: Vec<SafetyFindingGroupRow> = finding_rows
168 .into_iter()
169 .map(|r| SafetyFindingGroupRow {
170 category: r.category,
171 scanner: r.scanner,
172 severity: r.severity,
173 phase: r.phase,
174 findings: r.count,
175 blocked: r.blocked_count,
176 last_seen: r.last_seen.format("%Y-%m-%d %H:%M:%S").to_string(),
177 })
178 .collect();
179
180 let output = GovernanceReportOutput {
181 since: args.since.clone(),
182 group_by: args.group_by,
183 total_warnings,
184 warnings,
185 total_findings,
186 total_blocked_findings,
187 safety_findings,
188 };
189
190 Ok(output)
191}
192
193async fn execute_with_pool_inner(
194 args: ReportArgs,
195 pool: &Arc<sqlx::PgPool>,
196 config: &CliConfig,
197) -> Result<()> {
198 let output = gather(&args, pool).await?;
199
200 if args.format == ReportFormat::Csv {
201 let csv = format_csv(&output);
202 std::io::stdout().write_all(csv.as_bytes())?;
203 return Ok(());
204 }
205
206 render_result(
207 &CommandOutput::table_of(
208 vec![
209 "group",
210 "warnings",
211 "tools",
212 "users",
213 "last_seen",
214 "example_reason",
215 ],
216 &output.warnings,
217 )
218 .with_title(format!(
219 "Governance warnings by {} — {} in the last {}",
220 group_label(args.group_by),
221 output.total_warnings,
222 args.since
223 )),
224 config,
225 );
226
227 render_result(
228 &CommandOutput::table_of(
229 vec![
230 "category",
231 "scanner",
232 "severity",
233 "phase",
234 "findings",
235 "blocked",
236 "last_seen",
237 ],
238 &output.safety_findings,
239 )
240 .with_title(format!(
241 "Gateway safety findings — {} recorded, {} blocked",
242 output.total_findings, output.total_blocked_findings
243 )),
244 config,
245 );
246
247 Ok(())
248}
249
250const fn group_label(group_by: GroupBy) -> &'static str {
251 match group_by {
252 GroupBy::Policy => "policy",
253 GroupBy::Tool => "tool",
254 GroupBy::User => "user",
255 }
256}
257
258fn truncate(text: &str, max: usize) -> String {
259 if text.chars().count() <= max {
260 return text.to_owned();
261 }
262 let head: String = text.chars().take(max.saturating_sub(1)).collect();
263 format!("{head}…")
264}
265
266fn format_csv(output: &GovernanceReportOutput) -> String {
270 let mut csv = String::from("section,group,warnings,tools,users,last_seen,example_reason\n");
271 for row in &output.warnings {
272 csv.push_str(&format!(
273 "warning,{},{},{},{},{},\"{}\"\n",
274 csv_field(&row.group),
275 row.warnings,
276 row.tools,
277 row.users,
278 row.last_seen,
279 row.example_reason.replace('"', "\"\"")
280 ));
281 }
282 csv.push_str("\nsection,category,scanner,severity,phase,findings,blocked,last_seen\n");
283 for row in &output.safety_findings {
284 csv.push_str(&format!(
285 "finding,{},{},{},{},{},{},{}\n",
286 csv_field(&row.category),
287 csv_field(&row.scanner),
288 csv_field(&row.severity),
289 csv_field(&row.phase),
290 row.findings,
291 row.blocked,
292 row.last_seen
293 ));
294 }
295 csv
296}
297
298fn csv_field(value: &str) -> String {
299 if value.contains([',', '"', '\n']) {
300 return format!("\"{}\"", value.replace('"', "\"\""));
301 }
302 value.to_owned()
303}