Skip to main content

agent_runtime/commands/
audit_drift.rs

1use crate::audit_drift;
2use crate::render::manifest::SourceRoot;
3use clap::{Args, ValueEnum};
4use serde::Serialize;
5use std::path::PathBuf;
6
7#[derive(Args, Debug)]
8pub struct AuditDriftArgs {
9    /// Source root containing `manifests/`, `core/`, `targets/`, `build/`.
10    /// Defaults to the current working directory.
11    #[arg(long)]
12    pub source_root: Option<PathBuf>,
13
14    /// Include suppressed drift findings in the report.
15    #[arg(long)]
16    pub verbose: bool,
17
18    /// Severity that makes the command exit non-zero. `warn` (the default)
19    /// fails on any warn- or block-tier finding; `block` fails only on
20    /// block-tier findings, reporting warns without failing the gate.
21    #[arg(long = "fail-on", value_enum, default_value = "warn")]
22    pub fail_on: FailOn,
23
24    /// Output format.
25    #[arg(long, value_enum, default_value = "text")]
26    pub format: OutputFormat,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
30#[clap(rename_all = "lower")]
31pub enum OutputFormat {
32    Text,
33    Json,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
37#[clap(rename_all = "lower")]
38pub enum FailOn {
39    Warn,
40    Block,
41}
42
43impl FailOn {
44    fn label(self) -> &'static str {
45        match self {
46            FailOn::Warn => "warn",
47            FailOn::Block => "block",
48        }
49    }
50
51    /// Map the report's raw exit code to the effective exit code under this
52    /// gating policy. `warn` passes it through; `block` demotes the
53    /// warn-tier exit (1) to 0 while leaving block-tier (2) fatal.
54    fn gate(self, raw_exit: u8) -> u8 {
55        match self {
56            FailOn::Warn => raw_exit,
57            FailOn::Block if raw_exit >= BLOCK_EXIT_CODE => raw_exit,
58            FailOn::Block => 0,
59        }
60    }
61}
62
63const BLOCK_EXIT_CODE: u8 = 2;
64
65#[derive(Serialize)]
66struct AuditDriftJson<'a> {
67    schema_version: &'static str,
68    fail_on: &'static str,
69    total: usize,
70    block: usize,
71    warn: usize,
72    info: usize,
73    suppressed: usize,
74    exit_code: u8,
75    findings: &'a [&'a audit_drift::Finding],
76}
77
78pub fn run(args: AuditDriftArgs) -> anyhow::Result<u8> {
79    let root = SourceRoot::from_arg_or_cwd(args.source_root.as_deref())?;
80    let report = audit_drift::run(&root)?;
81
82    let visible_findings: Vec<&audit_drift::Finding> = report
83        .findings
84        .iter()
85        .filter(|f| args.verbose || f.severity != audit_drift::Severity::Suppressed)
86        .collect();
87
88    let raw_exit = report.exit_code();
89    let exit_code = args.fail_on.gate(raw_exit);
90
91    if args.format == OutputFormat::Json {
92        let envelope = AuditDriftJson {
93            schema_version: "agent-runtime-cli.audit-drift.v1",
94            fail_on: args.fail_on.label(),
95            total: visible_findings.len(),
96            block: count_severity(&report, audit_drift::Severity::Block),
97            warn: count_severity(&report, audit_drift::Severity::Warn),
98            info: count_severity(&report, audit_drift::Severity::Info),
99            suppressed: count_severity(&report, audit_drift::Severity::Suppressed),
100            exit_code,
101            findings: &visible_findings,
102        };
103        println!("{}", serde_json::to_string_pretty(&envelope)?);
104        return Ok(exit_code);
105    }
106
107    for f in &visible_findings {
108        eprintln!(
109            "audit-drift [{class}/{severity}{product}] {path}: {msg}",
110            class = f.class,
111            severity = f.severity.label(),
112            product = f
113                .product
114                .as_deref()
115                .map(|p| format!("/{p}"))
116                .unwrap_or_default(),
117            path = f.path.display(),
118            msg = f.message,
119        );
120    }
121    if exit_code == 0 && raw_exit != 0 {
122        eprintln!(
123            "audit-drift: {n} finding(s); highest raw severity exit={raw}, gated to 0 by --fail-on block",
124            n = visible_findings.len(),
125            raw = raw_exit,
126        );
127    } else if exit_code == 0 {
128        eprintln!("audit-drift: clean ({} findings)", visible_findings.len());
129    } else {
130        eprintln!(
131            "audit-drift: {n} finding(s); highest-severity exit={exit}",
132            n = visible_findings.len(),
133            exit = exit_code,
134        );
135    }
136    Ok(exit_code)
137}
138
139fn count_severity(report: &audit_drift::DriftReport, severity: audit_drift::Severity) -> usize {
140    report
141        .findings
142        .iter()
143        .filter(|f| f.severity == severity)
144        .count()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn fail_on_warn_passes_every_exit_code_through() {
153        assert_eq!(FailOn::Warn.gate(0), 0);
154        assert_eq!(FailOn::Warn.gate(1), 1);
155        assert_eq!(FailOn::Warn.gate(2), 2);
156    }
157
158    #[test]
159    fn fail_on_block_demotes_warn_but_keeps_block() {
160        assert_eq!(FailOn::Block.gate(0), 0);
161        assert_eq!(FailOn::Block.gate(1), 0, "warn-tier becomes non-fatal");
162        assert_eq!(FailOn::Block.gate(2), 2, "block-tier stays fatal");
163    }
164}