Skip to main content

apollo/
diagnostics.rs

1//! Shared diagnostics and security audit helpers.
2
3use crate::config::Config;
4use serde::Serialize;
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum Severity {
10    Info,
11    Warn,
12    Critical,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct Finding {
17    pub code: &'static str,
18    pub severity: Severity,
19    pub title: &'static str,
20    pub detail: String,
21    pub remediation: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct Check {
26    pub name: String,
27    pub ok: bool,
28    pub detail: String,
29    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
30    pub soft_warn: bool,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct DoctorReport {
35    pub findings: Vec<Finding>,
36    pub checks: Vec<Check>,
37}
38
39pub fn audit_config(cfg: &Config) -> Vec<Finding> {
40    let mut findings = Vec::new();
41
42    if cfg.policy.allow_shell {
43        findings.push(Finding {
44            code: "policy_shell_enabled",
45            severity: Severity::Info,
46            title: "Shell execution is enabled",
47            detail: "The exec tool can run host commands when invoked by the agent.".into(),
48            remediation: Some(
49                "Leave enabled only if this deployment is intended to be a full-computer agent."
50                    .into(),
51            ),
52        });
53    }
54
55    if cfg.policy.allow_dynamic_tools {
56        findings.push(Finding {
57            code: "policy_dynamic_tools_enabled",
58            severity: Severity::Warn,
59            title: "Dynamic tool creation/execution is enabled",
60            detail: "The agent can create and execute custom tools at runtime.".into(),
61            remediation: Some("Disable policy.allow_dynamic_tools for deployments that do not need self-extending tools.".into()),
62        });
63    }
64
65    if cfg.policy.allow_plugin_shell {
66        findings.push(Finding {
67            code: "policy_plugin_shell_enabled",
68            severity: Severity::Warn,
69            title: "Plugin shell execution is enabled",
70            detail: "Plugins are allowed to spawn shell commands.".into(),
71            remediation: Some(
72                "Disable policy.allow_plugin_shell unless plugins are trusted.".into(),
73            ),
74        });
75    }
76
77    if cfg.policy.allow_plugin_git {
78        findings.push(Finding {
79            code: "policy_plugin_git_enabled",
80            severity: Severity::Warn,
81            title: "Plugin git execution is enabled",
82            detail: "Plugins are allowed to run git operations directly.".into(),
83            remediation: Some("Disable policy.allow_plugin_git unless plugins are trusted.".into()),
84        });
85    }
86
87    if !Path::new(&cfg.workspace).exists() {
88        findings.push(Finding {
89            code: "workspace_missing",
90            severity: Severity::Warn,
91            title: "Configured workspace does not exist",
92            detail: format!(
93                "workspace=\"{}\" does not exist on disk.",
94                cfg.workspace.display()
95            ),
96            remediation: Some(
97                "Set workspace to an existing directory before starting the agent.".into(),
98            ),
99        });
100    }
101
102    if cfg.provider.api_key.is_none()
103        && std::env::var("ANTHROPIC_API_KEY").is_err()
104        && std::env::var("OPENAI_API_KEY").is_err()
105    {
106        findings.push(Finding {
107            code: "provider_credentials_missing",
108            severity: Severity::Warn,
109            title: "No provider credentials detected",
110            detail: "No API key is configured in the config or common environment variables."
111                .into(),
112            remediation: Some(
113                "Set provider.api_key or export a provider API key before starting chat.".into(),
114            ),
115        });
116    }
117
118    findings
119}
120
121pub async fn collect_doctor_report(
122    cfg: Option<&Config>,
123    config_path: Option<&str>,
124    verbose: bool,
125) -> DoctorReport {
126    let mut checks = Vec::new();
127    if let Some(path) = config_path {
128        checks.push(check_config_file(path));
129    }
130    let cfg = cfg.cloned().unwrap_or_else(|| {
131        config_path
132            .and_then(|path| Config::load(path).ok())
133            .unwrap_or_else(Config::default_config)
134    });
135
136    for (bin, label) in [
137        ("git", "Git"),
138        ("cargo", "Rust toolchain"),
139        ("ffmpeg", "FFmpeg"),
140        ("docker", "Docker"),
141        ("node", "Node.js"),
142    ] {
143        let found = check_cmd(bin).await;
144        if found || verbose {
145            checks.push(Check {
146                name: label.to_string(),
147                ok: found,
148                detail: if found {
149                    format!("{bin} is available")
150                } else {
151                    format!("{bin} is not on PATH")
152                },
153                soft_warn: false,
154            });
155        }
156    }
157
158    let workspace_exists = cfg.workspace.exists();
159    checks.push(Check {
160        name: "Workspace".into(),
161        ok: workspace_exists,
162        detail: cfg.workspace.display().to_string(),
163        soft_warn: false,
164    });
165
166    let workspace_writable = workspace_exists && is_workspace_writable(&cfg.workspace);
167    checks.push(Check {
168        name: "Workspace writable".into(),
169        ok: workspace_writable,
170        detail: if !workspace_exists {
171            "workspace does not exist".into()
172        } else if workspace_writable {
173            "workspace is writable".into()
174        } else {
175            "workspace is not writable".into()
176        },
177        soft_warn: false,
178    });
179
180    checks.push(local_bin_path_check());
181    checks.push(shared_login_check());
182
183    let provider_key_present = cfg.provider.api_key.is_some()
184        || std::env::var("ANTHROPIC_API_KEY").is_ok()
185        || std::env::var("OPENAI_API_KEY").is_ok();
186    checks.push(Check {
187        name: "Provider credentials".into(),
188        ok: provider_key_present,
189        detail: if provider_key_present {
190            "API credentials detected".into()
191        } else {
192            "No provider credentials detected".into()
193        },
194        soft_warn: false,
195    });
196
197    DoctorReport {
198        findings: audit_config(&cfg),
199        checks,
200    }
201}
202
203pub fn render_findings(findings: &[Finding]) -> String {
204    if findings.is_empty() {
205        return "No audit findings.".into();
206    }
207
208    findings
209        .iter()
210        .map(|finding| {
211            let severity = match finding.severity {
212                Severity::Info => "INFO",
213                Severity::Warn => "WARN",
214                Severity::Critical => "CRITICAL",
215            };
216            match &finding.remediation {
217                Some(remediation) => format!(
218                    "[{severity}] {} ({})\n{}\nRemediation: {}",
219                    finding.title, finding.code, finding.detail, remediation
220                ),
221                None => format!(
222                    "[{severity}] {} ({})\n{}",
223                    finding.title, finding.code, finding.detail
224                ),
225            }
226        })
227        .collect::<Vec<_>>()
228        .join("\n\n")
229}
230
231pub fn render_doctor_report(report: &DoctorReport) -> String {
232    let mut out = vec![
233        "apollo doctor".to_string(),
234        String::new(),
235        "Checks:".to_string(),
236    ];
237    for check in &report.checks {
238        let icon = if check.ok {
239            "OK"
240        } else if check.soft_warn {
241            "WARN"
242        } else {
243            "FAIL"
244        };
245        out.push(format!("- [{icon}] {}: {}", check.name, check.detail));
246    }
247    out.push(String::new());
248    out.push("Audit:".to_string());
249    out.push(render_findings(&report.findings));
250    out.join("\n")
251}
252
253pub(crate) fn check_config_file(path: &str) -> Check {
254    let file = Path::new(path);
255    if !file.exists() {
256        return Check {
257            name: "Config file".into(),
258            ok: false,
259            detail: format!("{path} not found"),
260            soft_warn: false,
261        };
262    }
263    match Config::load(path) {
264        Ok(_) => Check {
265            name: "Config file".into(),
266            ok: true,
267            detail: format!("{path} parses OK"),
268            soft_warn: false,
269        },
270        Err(err) => Check {
271            name: "Config file".into(),
272            ok: false,
273            detail: format!("{path} invalid: {err}"),
274            soft_warn: false,
275        },
276    }
277}
278
279fn is_workspace_writable(path: &Path) -> bool {
280    let probe = path.join(format!(".apollo-write-test-{}", std::process::id()));
281    match std::fs::File::create(&probe) {
282        Ok(_) => {
283            let _ = std::fs::remove_file(probe);
284            true
285        }
286        Err(_) => false,
287    }
288}
289
290/// Which providers have a usable credential in the store shared with
291/// telekinesis, so "am I logged in?" is answerable without guessing.
292///
293/// Reports provider names and expiry only — never a token.
294#[cfg(feature = "rs-ai")]
295fn shared_login_check() -> Check {
296    let logins = crate::providers::shared_credentials::logins();
297    if logins.is_empty() {
298        return Check {
299            name: "Shared login (rs_ai)".into(),
300            ok: false,
301            detail: "no provider logged in to the shared credential store".into(),
302            soft_warn: true,
303        };
304    }
305
306    let detail = logins
307        .iter()
308        .map(|login| match (login.expired, login.refreshable) {
309            (false, _) => login.provider.to_string(),
310            (true, true) => format!("{} (expired, refreshable)", login.provider),
311            (true, false) => format!("{} (expired)", login.provider),
312        })
313        .collect::<Vec<_>>()
314        .join(", ");
315
316    // Expired-but-refreshable is a working login: the provider refreshes it on
317    // first use. Only a dead, unrefreshable token means the user must log in
318    // again.
319    let usable = logins.iter().any(|l| !l.expired || l.refreshable);
320    Check {
321        name: "Shared login (rs_ai)".into(),
322        ok: usable,
323        detail,
324        soft_warn: !usable,
325    }
326}
327
328#[cfg(not(feature = "rs-ai"))]
329fn shared_login_check() -> Check {
330    Check {
331        name: "Shared login (rs_ai)".into(),
332        ok: false,
333        detail: "built without the rs-ai feature".into(),
334        soft_warn: true,
335    }
336}
337
338fn local_bin_path_check() -> Check {
339    let on_path = local_bin_on_path();
340    Check {
341        name: "~/.local/bin on PATH".into(),
342        ok: on_path,
343        detail: if on_path {
344            "~/.local/bin is on PATH".into()
345        } else {
346            "~/.local/bin is not on PATH (optional)".into()
347        },
348        soft_warn: true,
349    }
350}
351
352fn local_bin_on_path() -> bool {
353    let Ok(home) = std::env::var("HOME") else {
354        return false;
355    };
356    let local_bin = Path::new(&home).join(".local/bin");
357    let Ok(path_env) = std::env::var("PATH") else {
358        return false;
359    };
360    path_env
361        .split(':')
362        .any(|entry| Path::new(entry) == local_bin.as_path())
363}
364
365async fn check_cmd(cmd: &str) -> bool {
366    tokio::process::Command::new("which")
367        .arg(cmd)
368        .output()
369        .await
370        .map(|output| output.status.success())
371        .unwrap_or(false)
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn render_doctor_report_marks_soft_warn_and_fail() {
380        let report = DoctorReport {
381            findings: vec![],
382            checks: vec![
383                Check {
384                    name: "passing".into(),
385                    ok: true,
386                    detail: "all good".into(),
387                    soft_warn: false,
388                },
389                Check {
390                    name: "optional missing".into(),
391                    ok: false,
392                    detail: "not on PATH (optional)".into(),
393                    soft_warn: true,
394                },
395                Check {
396                    name: "required missing".into(),
397                    ok: false,
398                    detail: "not found".into(),
399                    soft_warn: false,
400                },
401            ],
402        };
403        let rendered = render_doctor_report(&report);
404        assert!(rendered.contains("- [OK] passing: all good"));
405        assert!(rendered.contains("- [WARN] optional missing: not on PATH (optional)"));
406        assert!(rendered.contains("- [FAIL] required missing: not found"));
407    }
408
409    #[test]
410    fn check_config_file_reports_missing_path() {
411        let path = "/tmp/apollo-doctor-missing-config-test-xyz123.json";
412        let check = check_config_file(path);
413        assert!(!check.ok);
414        assert!(!check.soft_warn);
415        assert_eq!(check.name, "Config file");
416        assert!(check.detail.contains("not found"));
417    }
418
419    #[test]
420    fn check_config_file_reports_valid_config() {
421        let dir = tempfile::tempdir().unwrap();
422        let path = dir.path().join("apollo.json");
423        std::fs::write(&path, "{}").unwrap();
424        let check = check_config_file(path.to_str().unwrap());
425        assert!(check.ok);
426        assert!(!check.soft_warn);
427        assert!(check.detail.contains("parses OK"));
428    }
429}