agentguard/commands/
doctor.rs

1//! `agentguard doctor` — diagnose a deployment.
2//!
3//! Checks schema loads, policies parse, schema validation passes, audit log
4//! writable, hash chain (if configured) verifies, telemetry configured.
5
6use agentguard_core::decision::DecisionLog;
7use agentguard_core::decode_chain_secret;
8use agentguard_core::policy::PolicyStore;
9use anyhow::Result;
10use std::path::Path;
11
12/// Status of a single diagnostic check.
13#[derive(Debug, Clone)]
14pub enum CheckStatus {
15    Ok,
16    Warn(String),
17    Fail(String),
18}
19
20impl CheckStatus {
21    /// Single-character symbol for terminal output.
22    pub fn symbol(&self) -> &'static str {
23        match self {
24            CheckStatus::Ok => "✓",
25            CheckStatus::Warn(_) => "!",
26            CheckStatus::Fail(_) => "✗",
27        }
28    }
29}
30
31/// Aggregated doctor report.
32#[derive(Debug, Clone)]
33pub struct DoctorReport {
34    pub checks: Vec<(&'static str, CheckStatus)>,
35}
36
37impl DoctorReport {
38    pub fn has_failures(&self) -> bool {
39        self.checks
40            .iter()
41            .any(|(_, s)| matches!(s, CheckStatus::Fail(_)))
42    }
43    pub fn has_warnings(&self) -> bool {
44        self.checks
45            .iter()
46            .any(|(_, s)| matches!(s, CheckStatus::Warn(_)))
47    }
48    pub fn print(&self) {
49        for (name, status) in &self.checks {
50            match status {
51                CheckStatus::Ok => println!("  \x1b[32m{}\x1b[0m {}", status.symbol(), name),
52                CheckStatus::Warn(msg) => {
53                    println!("  \x1b[33m{}\x1b[0m {}: {}", status.symbol(), name, msg)
54                }
55                CheckStatus::Fail(msg) => {
56                    println!("  \x1b[31m{}\x1b[0m {}: {}", status.symbol(), name, msg)
57                }
58            }
59        }
60    }
61}
62
63pub fn run(
64    store_root: &Path,
65    audit_log: &Path,
66    chain_secret: Option<&Path>,
67) -> Result<DoctorReport> {
68    let mut report = DoctorReport { checks: vec![] };
69
70    // 1. Schema
71    let store = match PolicyStore::open(store_root) {
72        Ok(s) => s,
73        Err(e) => {
74            report
75                .checks
76                .push(("schema", CheckStatus::Fail(e.to_string())));
77            return Ok(report);
78        }
79    };
80    let schema = store.load_schema().ok().flatten();
81    match &schema {
82        Some(_) => report.checks.push(("schema", CheckStatus::Ok)),
83        None => report.checks.push((
84            "schema",
85            CheckStatus::Warn("no schema.cedarschema at store root".into()),
86        )),
87    }
88
89    // 2. Policies
90    let validation = match store.validate() {
91        Ok(v) => v,
92        Err(e) => {
93            report
94                .checks
95                .push(("policies", CheckStatus::Fail(e.to_string())));
96            return Ok(report);
97        }
98    };
99    if validation.is_ok() {
100        report.checks.push(("policies", CheckStatus::Ok));
101    } else {
102        let errs: Vec<String> = validation
103            .errors
104            .iter()
105            .map(|e| e.message.clone())
106            .collect();
107        report
108            .checks
109            .push(("policies", CheckStatus::Fail(errs.join("; "))));
110    }
111
112    // 3. Audit log writable
113    if audit_log.exists() {
114        match std::fs::OpenOptions::new().append(true).open(audit_log) {
115            Ok(_) => report.checks.push(("audit log", CheckStatus::Ok)),
116            Err(e) => report
117                .checks
118                .push(("audit log", CheckStatus::Fail(e.to_string()))),
119        }
120    } else if let Some(parent) = audit_log.parent() {
121        match std::fs::create_dir_all(parent) {
122            Ok(_) => report.checks.push(("audit log", CheckStatus::Ok)),
123            Err(e) => report
124                .checks
125                .push(("audit log", CheckStatus::Fail(e.to_string()))),
126        }
127    } else {
128        report.checks.push(("audit log", CheckStatus::Ok));
129    }
130
131    // 4. Hash chain
132    if let Some(secret_path) = chain_secret {
133        if let Ok(key) = std::fs::read(secret_path) {
134            if let Some(key_bytes) = decode_chain_secret(&key) {
135                match DecisionLog::verify_chain(audit_log, &key_bytes) {
136                    Ok(_) => report.checks.push(("hash chain", CheckStatus::Ok)),
137                    Err(e) => report
138                        .checks
139                        .push(("hash chain", CheckStatus::Fail(e.to_string()))),
140                }
141            } else {
142                report.checks.push((
143                    "hash chain",
144                    CheckStatus::Warn(format!("secret file {} is empty", secret_path.display())),
145                ));
146            }
147        } else {
148            report.checks.push((
149                "hash chain",
150                CheckStatus::Warn(format!("cannot read secret file {}", secret_path.display())),
151            ));
152        }
153    } else {
154        report.checks.push((
155            "hash chain",
156            CheckStatus::Warn("no chain secret configured (AGENTGUARD_CHAIN_SECRET unset)".into()),
157        ));
158    }
159
160    // 5. Authorizer warm
161    match agentguard_core::Authorizer::new(store) {
162        Ok(_) => report.checks.push(("authorizer", CheckStatus::Ok)),
163        Err(e) => report
164            .checks
165            .push(("authorizer", CheckStatus::Fail(e.to_string()))),
166    }
167
168    Ok(report)
169}