agent_runtime/commands/
audit_drift.rs1use 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 #[arg(long)]
12 pub source_root: Option<PathBuf>,
13
14 #[arg(long)]
16 pub verbose: bool,
17
18 #[arg(long = "fail-on", value_enum, default_value = "warn")]
22 pub fail_on: FailOn,
23
24 #[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 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}