Skip to main content

systemprompt_cli/commands/infrastructure/logs/governance/
report.rs

1//! `infra logs governance report`: what warn mode caught, and what it would
2//! have blocked.
3//!
4//! Two planes are reported side by side because they are configured
5//! separately and fail differently: the governance chain
6//! (`services/governance/config.yaml`) writes `governance_decisions`, and the
7//! gateway safety scanners (`services/gateway/policies.yaml`) write
8//! `ai_safety_findings`. A category that fires constantly with a zero blocked
9//! count is a tunable that wants lowering; one that never fires at all is a
10//! scanner that is not earning its place.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use 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/// Which dimension of the warn rollup to collapse onto.
31#[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// Why: accumulates in a `BTreeMap` rather than sorting at the end so the
109// distinct tool and user counts per group are exact. Summing the SQL rollup's
110// per-combination counts would double-count a tool used by two users.
111#[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
266// Why: two sections in one CSV, separated by a blank line and a fresh header
267// row. A spreadsheet reads that as two blocks, and the alternative — two files
268// or two invocations — loses the fact that both windows are the same.
269fn 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}