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() && std::env::var("OPENAI_API_KEY").is_err() {
103        findings.push(Finding {
104            code: "provider_credentials_missing",
105            severity: Severity::Warn,
106            title: "No provider credentials detected",
107            detail: "No API key is configured in the config or common environment variables."
108                .into(),
109            remediation: Some(
110                "Set provider.api_key or export a provider API key before starting chat.".into(),
111            ),
112        });
113    }
114
115    findings
116}
117
118pub async fn collect_doctor_report(
119    cfg: Option<&Config>,
120    config_path: Option<&str>,
121    verbose: bool,
122) -> DoctorReport {
123    let mut checks = Vec::new();
124    if let Some(path) = config_path {
125        checks.push(check_config_file(path));
126    }
127    let cfg = cfg.cloned().unwrap_or_else(|| {
128        config_path
129            .and_then(|path| Config::load(path).ok())
130            .unwrap_or_else(Config::default_config)
131    });
132
133    for (bin, label) in [
134        ("git", "Git"),
135        ("cargo", "Rust toolchain"),
136        ("ffmpeg", "FFmpeg"),
137        ("docker", "Docker"),
138        ("node", "Node.js"),
139    ] {
140        let found = check_cmd(bin).await;
141        if found || verbose {
142            checks.push(Check {
143                name: label.to_string(),
144                ok: found,
145                detail: if found {
146                    format!("{bin} is available")
147                } else {
148                    format!("{bin} is not on PATH")
149                },
150                soft_warn: false,
151            });
152        }
153    }
154
155    let workspace_exists = cfg.workspace.exists();
156    checks.push(Check {
157        name: "Workspace".into(),
158        ok: workspace_exists,
159        detail: cfg.workspace.display().to_string(),
160        soft_warn: false,
161    });
162
163    let workspace_writable = workspace_exists && is_workspace_writable(&cfg.workspace);
164    checks.push(Check {
165        name: "Workspace writable".into(),
166        ok: workspace_writable,
167        detail: if !workspace_exists {
168            "workspace does not exist".into()
169        } else if workspace_writable {
170            "workspace is writable".into()
171        } else {
172            "workspace is not writable".into()
173        },
174        soft_warn: false,
175    });
176
177    checks.push(local_bin_path_check());
178    checks.push(shared_login_check());
179
180    let provider_key_present =
181        cfg.provider.api_key.is_some() || std::env::var("OPENAI_API_KEY").is_ok();
182    checks.push(Check {
183        name: "Provider credentials".into(),
184        ok: provider_key_present,
185        detail: if provider_key_present {
186            "API credentials detected".into()
187        } else {
188            "No provider credentials detected".into()
189        },
190        soft_warn: false,
191    });
192
193    DoctorReport {
194        findings: audit_config(&cfg),
195        checks,
196    }
197}
198
199pub fn render_findings(findings: &[Finding]) -> String {
200    if findings.is_empty() {
201        return "No audit findings.".into();
202    }
203
204    findings
205        .iter()
206        .map(|finding| {
207            let severity = match finding.severity {
208                Severity::Info => "INFO",
209                Severity::Warn => "WARN",
210                Severity::Critical => "CRITICAL",
211            };
212            match &finding.remediation {
213                Some(remediation) => format!(
214                    "[{severity}] {} ({})\n{}\nRemediation: {}",
215                    finding.title, finding.code, finding.detail, remediation
216                ),
217                None => format!(
218                    "[{severity}] {} ({})\n{}",
219                    finding.title, finding.code, finding.detail
220                ),
221            }
222        })
223        .collect::<Vec<_>>()
224        .join("\n\n")
225}
226
227pub fn render_doctor_report(report: &DoctorReport) -> String {
228    let mut out = vec![
229        "apollo doctor".to_string(),
230        String::new(),
231        "Checks:".to_string(),
232    ];
233    for check in &report.checks {
234        let icon = if check.ok {
235            "OK"
236        } else if check.soft_warn {
237            "WARN"
238        } else {
239            "FAIL"
240        };
241        out.push(format!("- [{icon}] {}: {}", check.name, check.detail));
242    }
243    out.push(String::new());
244    out.push("Audit:".to_string());
245    out.push(render_findings(&report.findings));
246    out.join("\n")
247}
248
249pub(crate) fn check_config_file(path: &str) -> Check {
250    let file = Path::new(path);
251    if !file.exists() {
252        return Check {
253            name: "Config file".into(),
254            ok: false,
255            detail: format!("{path} not found"),
256            soft_warn: false,
257        };
258    }
259    match Config::load(path) {
260        Ok(_) => Check {
261            name: "Config file".into(),
262            ok: true,
263            detail: format!("{path} parses OK"),
264            soft_warn: false,
265        },
266        Err(err) => Check {
267            name: "Config file".into(),
268            ok: false,
269            detail: format!("{path} invalid: {err}"),
270            soft_warn: false,
271        },
272    }
273}
274
275fn is_workspace_writable(path: &Path) -> bool {
276    let probe = path.join(format!(".apollo-write-test-{}", std::process::id()));
277    match std::fs::File::create(&probe) {
278        Ok(_) => {
279            let _ = std::fs::remove_file(probe);
280            true
281        }
282        Err(_) => false,
283    }
284}
285
286/// Which providers have a usable credential in the store shared with
287/// telekinesis, so "am I logged in?" is answerable without guessing.
288///
289/// Reports provider names and expiry only — never a token.
290#[cfg(feature = "rs-ai")]
291fn shared_login_check() -> Check {
292    let logins = crate::providers::shared_credentials::logins();
293    if logins.is_empty() {
294        return Check {
295            name: "Shared login (rs_ai)".into(),
296            ok: false,
297            detail: "no provider logged in to the shared credential store".into(),
298            soft_warn: true,
299        };
300    }
301
302    let detail = logins
303        .iter()
304        .map(|login| match (login.expired, login.refreshable) {
305            (false, _) => login.provider.to_string(),
306            (true, true) => format!("{} (expired, refreshable)", login.provider),
307            (true, false) => format!("{} (expired)", login.provider),
308        })
309        .collect::<Vec<_>>()
310        .join(", ");
311
312    // Expired-but-refreshable is a working login: the provider refreshes it on
313    // first use. Only a dead, unrefreshable token means the user must log in
314    // again.
315    let usable = logins.iter().any(|l| !l.expired || l.refreshable);
316    Check {
317        name: "Shared login (rs_ai)".into(),
318        ok: usable,
319        detail,
320        soft_warn: !usable,
321    }
322}
323
324#[cfg(not(feature = "rs-ai"))]
325fn shared_login_check() -> Check {
326    Check {
327        name: "Shared login (rs_ai)".into(),
328        ok: false,
329        detail: "built without the rs-ai feature".into(),
330        soft_warn: true,
331    }
332}
333
334fn local_bin_path_check() -> Check {
335    let on_path = local_bin_on_path();
336    Check {
337        name: "~/.local/bin on PATH".into(),
338        ok: on_path,
339        detail: if on_path {
340            "~/.local/bin is on PATH".into()
341        } else {
342            "~/.local/bin is not on PATH (optional)".into()
343        },
344        soft_warn: true,
345    }
346}
347
348fn local_bin_on_path() -> bool {
349    let Ok(home) = std::env::var("HOME") else {
350        return false;
351    };
352    let local_bin = Path::new(&home).join(".local/bin");
353    let Ok(path_env) = std::env::var("PATH") else {
354        return false;
355    };
356    path_env
357        .split(':')
358        .any(|entry| Path::new(entry) == local_bin.as_path())
359}
360
361async fn check_cmd(cmd: &str) -> bool {
362    tokio::process::Command::new("which")
363        .arg(cmd)
364        .output()
365        .await
366        .map(|output| output.status.success())
367        .unwrap_or(false)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn render_doctor_report_marks_soft_warn_and_fail() {
376        let report = DoctorReport {
377            findings: vec![],
378            checks: vec![
379                Check {
380                    name: "passing".into(),
381                    ok: true,
382                    detail: "all good".into(),
383                    soft_warn: false,
384                },
385                Check {
386                    name: "optional missing".into(),
387                    ok: false,
388                    detail: "not on PATH (optional)".into(),
389                    soft_warn: true,
390                },
391                Check {
392                    name: "required missing".into(),
393                    ok: false,
394                    detail: "not found".into(),
395                    soft_warn: false,
396                },
397            ],
398        };
399        let rendered = render_doctor_report(&report);
400        assert!(rendered.contains("- [OK] passing: all good"));
401        assert!(rendered.contains("- [WARN] optional missing: not on PATH (optional)"));
402        assert!(rendered.contains("- [FAIL] required missing: not found"));
403    }
404
405    #[test]
406    fn check_config_file_reports_missing_path() {
407        let path = "/tmp/apollo-doctor-missing-config-test-xyz123.json";
408        let check = check_config_file(path);
409        assert!(!check.ok);
410        assert!(!check.soft_warn);
411        assert_eq!(check.name, "Config file");
412        assert!(check.detail.contains("not found"));
413    }
414
415    #[test]
416    fn check_config_file_reports_valid_config() {
417        let dir = tempfile::tempdir().unwrap();
418        let path = dir.path().join("apollo.json");
419        std::fs::write(&path, "{}").unwrap();
420        let check = check_config_file(path.to_str().unwrap());
421        assert!(check.ok);
422        assert!(!check.soft_warn);
423        assert!(check.detail.contains("parses OK"));
424    }
425}