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
7pub const DEFAULT_GATEWAY_HTTP_TOOL_DENY: &[&str] = &[
8    "exec",
9    "create_tool",
10    "browser",
11    "mcp",
12    "vibemania",
13    "message",
14    "Write",
15    "Edit",
16];
17
18pub const APPROVAL_REQUIRED_TOOLS: &[&str] = &[
19    "exec",
20    "create_tool",
21    "Write",
22    "Edit",
23    "browser",
24    "vibemania",
25];
26
27#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum Severity {
30    Info,
31    Warn,
32    Critical,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct Finding {
37    pub code: &'static str,
38    pub severity: Severity,
39    pub title: &'static str,
40    pub detail: String,
41    pub remediation: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct ToolClassification {
46    pub name: String,
47    pub risk: Severity,
48    pub denied_over_gateway_http_by_default: bool,
49    pub approval_required: bool,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct Check {
54    pub name: String,
55    pub ok: bool,
56    pub detail: String,
57    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58    pub soft_warn: bool,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct DoctorReport {
63    pub findings: Vec<Finding>,
64    pub checks: Vec<Check>,
65}
66
67pub fn classify_tool(name: &str) -> ToolClassification {
68    let denied = DEFAULT_GATEWAY_HTTP_TOOL_DENY
69        .iter()
70        .any(|tool| tool.eq_ignore_ascii_case(name));
71    let approval = APPROVAL_REQUIRED_TOOLS
72        .iter()
73        .any(|tool| tool.eq_ignore_ascii_case(name));
74    let risk = if denied {
75        Severity::Critical
76    } else if approval {
77        Severity::Warn
78    } else {
79        Severity::Info
80    };
81
82    ToolClassification {
83        name: name.to_string(),
84        risk,
85        denied_over_gateway_http_by_default: denied,
86        approval_required: approval,
87    }
88}
89
90pub fn audit_config(cfg: &Config) -> Vec<Finding> {
91    let mut findings = Vec::new();
92    let bind = cfg.gateway.bind.trim();
93    let is_loopback = is_loopback_bind(bind);
94    let auth_token = cfg.gateway.auth_token.as_deref().unwrap_or("").trim();
95    let has_auth = !auth_token.is_empty();
96
97    if !is_loopback && !has_auth {
98        findings.push(Finding {
99            code: "gateway_bind_no_auth",
100            severity: Severity::Critical,
101            title: "Gateway binds beyond loopback without auth",
102            detail: format!("gateway.bind=\"{bind}\" but no gateway.auth_token is configured."),
103            remediation: Some("Bind to localhost or configure a long random bearer token.".into()),
104        });
105    } else if is_loopback && !has_auth {
106        findings.push(Finding {
107            code: "gateway_loopback_no_auth",
108            severity: Severity::Warn,
109            title: "Gateway auth missing on loopback",
110            detail: "The gateway is loopback-only, but any local process can call it without a bearer token."
111                .into(),
112            remediation: Some("Set gateway.auth_token even for local-only deployments.".into()),
113        });
114    }
115
116    if has_auth && auth_token.len() < 24 {
117        findings.push(Finding {
118            code: "gateway_token_short",
119            severity: Severity::Warn,
120            title: "Gateway token looks short",
121            detail: format!(
122                "gateway.auth_token is only {} characters; use a long random token.",
123                auth_token.len()
124            ),
125            remediation: Some("Use at least 24 random characters.".into()),
126        });
127    }
128
129    if cfg.gateway.enable_admin_api {
130        findings.push(Finding {
131            code: "gateway_admin_api_enabled",
132            severity: if is_loopback {
133                Severity::Warn
134            } else {
135                Severity::Critical
136            },
137            title: "Gateway admin API is enabled",
138            detail: "Admin endpoints for memory/tools/swarm/plugins are enabled. This should stay disabled unless you are intentionally exposing a control-plane surface.".into(),
139            remediation: Some("Keep gateway.enable_admin_api=false unless you are actively wiring and securing those endpoints.".into()),
140        });
141    }
142
143    if cfg.gateway.rate_limit_per_minute == 0 {
144        findings.push(Finding {
145            code: "gateway_rate_limit_disabled",
146            severity: Severity::Warn,
147            title: "Gateway rate limiting is disabled",
148            detail: "gateway.rate_limit_per_minute is 0, so authenticated clients can send unlimited requests.".into(),
149            remediation: Some("Set a sane per-minute request limit for hosted deployments.".into()),
150        });
151    }
152
153    if cfg.gateway.request_timeout_secs > 300 {
154        findings.push(Finding {
155            code: "gateway_timeout_high",
156            severity: Severity::Warn,
157            title: "Gateway request timeout is unusually high",
158            detail: format!(
159                "gateway.request_timeout_secs is set to {} seconds.",
160                cfg.gateway.request_timeout_secs
161            ),
162            remediation: Some(
163                "Keep request timeouts bounded to reduce stuck sessions and resource exhaustion."
164                    .into(),
165            ),
166        });
167    }
168
169    if !is_loopback && cfg.gateway.allowed_origins.is_empty() {
170        findings.push(Finding {
171            code: "gateway_origins_unrestricted",
172            severity: Severity::Warn,
173            title: "Gateway allowed origins are unrestricted",
174            detail: "The gateway is not loopback-only and gateway.allowed_origins is empty.".into(),
175            remediation: Some("Set explicit allowed origins when exposing the gateway behind a browser-facing frontend.".into()),
176        });
177    }
178
179    if cfg.policy.allow_shell {
180        findings.push(Finding {
181            code: "policy_shell_enabled",
182            severity: Severity::Info,
183            title: "Shell execution is enabled",
184            detail: "The exec tool can run host commands when invoked by the agent.".into(),
185            remediation: Some(
186                "Leave enabled only if this deployment is intended to be a full-computer agent."
187                    .into(),
188            ),
189        });
190    }
191
192    if cfg.policy.allow_dynamic_tools {
193        findings.push(Finding {
194            code: "policy_dynamic_tools_enabled",
195            severity: Severity::Warn,
196            title: "Dynamic tool creation/execution is enabled",
197            detail: "The agent can create and execute custom tools at runtime.".into(),
198            remediation: Some("Disable policy.allow_dynamic_tools for deployments that do not need self-extending tools.".into()),
199        });
200    }
201
202    if cfg.policy.allow_plugin_shell {
203        findings.push(Finding {
204            code: "policy_plugin_shell_enabled",
205            severity: Severity::Warn,
206            title: "Plugin shell execution is enabled",
207            detail: "Plugins are allowed to spawn shell commands.".into(),
208            remediation: Some(
209                "Disable policy.allow_plugin_shell unless plugins are trusted.".into(),
210            ),
211        });
212    }
213
214    if cfg.policy.allow_plugin_git {
215        findings.push(Finding {
216            code: "policy_plugin_git_enabled",
217            severity: Severity::Warn,
218            title: "Plugin git execution is enabled",
219            detail: "Plugins are allowed to run git operations directly.".into(),
220            remediation: Some("Disable policy.allow_plugin_git unless plugins are trusted.".into()),
221        });
222    }
223
224    if !Path::new(&cfg.workspace).exists() {
225        findings.push(Finding {
226            code: "workspace_missing",
227            severity: Severity::Warn,
228            title: "Configured workspace does not exist",
229            detail: format!(
230                "workspace=\"{}\" does not exist on disk.",
231                cfg.workspace.display()
232            ),
233            remediation: Some(
234                "Set workspace to an existing directory before starting the agent.".into(),
235            ),
236        });
237    }
238
239    if cfg.provider.api_key.is_none()
240        && std::env::var("ANTHROPIC_API_KEY").is_err()
241        && std::env::var("OPENAI_API_KEY").is_err()
242    {
243        findings.push(Finding {
244            code: "provider_credentials_missing",
245            severity: Severity::Warn,
246            title: "No provider credentials detected",
247            detail: "No API key is configured in the config or common environment variables."
248                .into(),
249            remediation: Some(
250                "Set provider.api_key or export a provider API key before starting chat/gateway."
251                    .into(),
252            ),
253        });
254    }
255
256    findings
257}
258
259pub async fn collect_doctor_report(
260    cfg: Option<&Config>,
261    config_path: Option<&str>,
262    verbose: bool,
263) -> DoctorReport {
264    let mut checks = Vec::new();
265    if let Some(path) = config_path {
266        checks.push(check_config_file(path));
267    }
268    let cfg = cfg.cloned().unwrap_or_else(|| {
269        config_path
270            .and_then(|path| Config::load(path).ok())
271            .unwrap_or_else(Config::default_config)
272    });
273
274    for (bin, label) in [
275        ("git", "Git"),
276        ("cargo", "Rust toolchain"),
277        ("ffmpeg", "FFmpeg"),
278        ("docker", "Docker"),
279        ("node", "Node.js"),
280    ] {
281        let found = check_cmd(bin).await;
282        if found || verbose {
283            checks.push(Check {
284                name: label.to_string(),
285                ok: found,
286                detail: if found {
287                    format!("{bin} is available")
288                } else {
289                    format!("{bin} is not on PATH")
290                },
291                soft_warn: false,
292            });
293        }
294    }
295
296    let workspace_exists = cfg.workspace.exists();
297    checks.push(Check {
298        name: "Workspace".into(),
299        ok: workspace_exists,
300        detail: cfg.workspace.display().to_string(),
301        soft_warn: false,
302    });
303
304    let workspace_writable = workspace_exists && is_workspace_writable(&cfg.workspace);
305    checks.push(Check {
306        name: "Workspace writable".into(),
307        ok: workspace_writable,
308        detail: if !workspace_exists {
309            "workspace does not exist".into()
310        } else if workspace_writable {
311            "workspace is writable".into()
312        } else {
313            "workspace is not writable".into()
314        },
315        soft_warn: false,
316    });
317
318    checks.push(local_bin_path_check());
319
320    let provider_key_present = cfg.provider.api_key.is_some()
321        || std::env::var("ANTHROPIC_API_KEY").is_ok()
322        || std::env::var("OPENAI_API_KEY").is_ok();
323    checks.push(Check {
324        name: "Provider credentials".into(),
325        ok: provider_key_present,
326        detail: if provider_key_present {
327            "API credentials detected".into()
328        } else {
329            "No provider credentials detected".into()
330        },
331        soft_warn: false,
332    });
333
334    checks.push(Check {
335        name: "Gateway bind".into(),
336        ok: is_loopback_bind(cfg.gateway.bind.trim()),
337        detail: cfg.gateway.bind.clone(),
338        soft_warn: false,
339    });
340
341    checks.push(Check {
342        name: "Gateway rate limit".into(),
343        ok: cfg.gateway.rate_limit_per_minute > 0,
344        detail: format!("{} req/min", cfg.gateway.rate_limit_per_minute),
345        soft_warn: false,
346    });
347
348    checks.push(Check {
349        name: "Gateway timeout".into(),
350        ok: cfg.gateway.request_timeout_secs > 0,
351        detail: format!("{}s", cfg.gateway.request_timeout_secs),
352        soft_warn: false,
353    });
354
355    DoctorReport {
356        findings: audit_config(&cfg),
357        checks,
358    }
359}
360
361pub fn render_findings(findings: &[Finding]) -> String {
362    if findings.is_empty() {
363        return "No audit findings.".into();
364    }
365
366    findings
367        .iter()
368        .map(|finding| {
369            let severity = match finding.severity {
370                Severity::Info => "INFO",
371                Severity::Warn => "WARN",
372                Severity::Critical => "CRITICAL",
373            };
374            match &finding.remediation {
375                Some(remediation) => format!(
376                    "[{severity}] {} ({})\n{}\nRemediation: {}",
377                    finding.title, finding.code, finding.detail, remediation
378                ),
379                None => format!(
380                    "[{severity}] {} ({})\n{}",
381                    finding.title, finding.code, finding.detail
382                ),
383            }
384        })
385        .collect::<Vec<_>>()
386        .join("\n\n")
387}
388
389pub fn render_doctor_report(report: &DoctorReport) -> String {
390    let mut out = vec![
391        "apollo doctor".to_string(),
392        String::new(),
393        "Checks:".to_string(),
394    ];
395    for check in &report.checks {
396        let icon = if check.ok {
397            "OK"
398        } else if check.soft_warn {
399            "WARN"
400        } else {
401            "FAIL"
402        };
403        out.push(format!("- [{icon}] {}: {}", check.name, check.detail));
404    }
405    out.push(String::new());
406    out.push("Audit:".to_string());
407    out.push(render_findings(&report.findings));
408    out.join("\n")
409}
410
411pub(crate) fn check_config_file(path: &str) -> Check {
412    let file = Path::new(path);
413    if !file.exists() {
414        return Check {
415            name: "Config file".into(),
416            ok: false,
417            detail: format!("{path} not found"),
418            soft_warn: false,
419        };
420    }
421    match Config::load(path) {
422        Ok(_) => Check {
423            name: "Config file".into(),
424            ok: true,
425            detail: format!("{path} parses OK"),
426            soft_warn: false,
427        },
428        Err(err) => Check {
429            name: "Config file".into(),
430            ok: false,
431            detail: format!("{path} invalid: {err}"),
432            soft_warn: false,
433        },
434    }
435}
436
437fn is_workspace_writable(path: &Path) -> bool {
438    let probe = path.join(format!(".apollo-write-test-{}", std::process::id()));
439    match std::fs::File::create(&probe) {
440        Ok(_) => {
441            let _ = std::fs::remove_file(probe);
442            true
443        }
444        Err(_) => false,
445    }
446}
447
448fn local_bin_path_check() -> Check {
449    let on_path = local_bin_on_path();
450    Check {
451        name: "~/.local/bin on PATH".into(),
452        ok: on_path,
453        detail: if on_path {
454            "~/.local/bin is on PATH".into()
455        } else {
456            "~/.local/bin is not on PATH (optional)".into()
457        },
458        soft_warn: true,
459    }
460}
461
462fn local_bin_on_path() -> bool {
463    let Ok(home) = std::env::var("HOME") else {
464        return false;
465    };
466    let local_bin = Path::new(&home).join(".local/bin");
467    let Ok(path_env) = std::env::var("PATH") else {
468        return false;
469    };
470    path_env
471        .split(':')
472        .any(|entry| Path::new(entry) == local_bin.as_path())
473}
474
475fn is_loopback_bind(bind: &str) -> bool {
476    let host = if let Some(stripped) = bind.strip_prefix('[') {
477        stripped.split(']').next().unwrap_or(bind)
478    } else {
479        bind.rsplit_once(':').map(|(host, _)| host).unwrap_or(bind)
480    };
481    matches!(host, "127.0.0.1" | "localhost" | "::1")
482}
483
484async fn check_cmd(cmd: &str) -> bool {
485    tokio::process::Command::new("which")
486        .arg(cmd)
487        .output()
488        .await
489        .map(|output| output.status.success())
490        .unwrap_or(false)
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::config::Config;
497
498    #[test]
499    fn audit_flags_non_loopback_gateway_without_auth() {
500        let mut cfg = Config::default_config();
501        cfg.gateway.bind = "0.0.0.0:8080".into();
502        cfg.gateway.auth_token = None;
503        let findings = audit_config(&cfg);
504        assert!(findings.iter().any(|f| f.code == "gateway_bind_no_auth"));
505    }
506
507    #[test]
508    fn classify_exec_as_high_risk() {
509        let tool = classify_tool("exec");
510        assert_eq!(tool.risk, Severity::Critical);
511        assert!(tool.denied_over_gateway_http_by_default);
512        assert!(tool.approval_required);
513    }
514
515    #[test]
516    fn audit_flags_loopback_gateway_without_auth() {
517        let mut cfg = Config::default_config();
518        cfg.gateway.bind = "127.0.0.1:8080".into();
519        cfg.gateway.auth_token = None;
520        let findings = audit_config(&cfg);
521        assert!(findings
522            .iter()
523            .any(|f| f.code == "gateway_loopback_no_auth" && f.severity == Severity::Warn));
524    }
525
526    #[test]
527    fn audit_flags_short_auth_token() {
528        let mut cfg = Config::default_config();
529        cfg.gateway.bind = "127.0.0.1:8080".into();
530        cfg.gateway.auth_token = Some("short".into());
531        let findings = audit_config(&cfg);
532        assert!(findings
533            .iter()
534            .any(|f| f.code == "gateway_token_short" && f.severity == Severity::Warn));
535    }
536
537    #[test]
538    fn audit_flags_admin_api_enabled_loopback() {
539        let mut cfg = Config::default_config();
540        cfg.gateway.bind = "127.0.0.1:8080".into();
541        cfg.gateway.auth_token = Some("a_very_long_auth_token_string_here_for_testing".into());
542        cfg.gateway.enable_admin_api = true;
543        let findings = audit_config(&cfg);
544        assert!(findings
545            .iter()
546            .any(|f| f.code == "gateway_admin_api_enabled" && f.severity == Severity::Warn));
547    }
548
549    #[test]
550    fn audit_flags_admin_api_enabled_non_loopback() {
551        let mut cfg = Config::default_config();
552        cfg.gateway.bind = "0.0.0.0:8080".into();
553        cfg.gateway.auth_token = Some("a_very_long_auth_token_string_here_for_testing".into());
554        cfg.gateway.enable_admin_api = true;
555        let findings = audit_config(&cfg);
556        assert!(findings
557            .iter()
558            .any(|f| f.code == "gateway_admin_api_enabled" && f.severity == Severity::Critical));
559    }
560
561    #[test]
562    fn audit_flags_rate_limit_disabled() {
563        let mut cfg = Config::default_config();
564        cfg.gateway.bind = "127.0.0.1:8080".into();
565        cfg.gateway.auth_token = Some("a_very_long_auth_token_string_here_for_testing".into());
566        cfg.gateway.rate_limit_per_minute = 0;
567        let findings = audit_config(&cfg);
568        assert!(findings
569            .iter()
570            .any(|f| f.code == "gateway_rate_limit_disabled" && f.severity == Severity::Warn));
571    }
572
573    #[test]
574    fn audit_flags_timeout_high() {
575        let mut cfg = Config::default_config();
576        cfg.gateway.bind = "127.0.0.1:8080".into();
577        cfg.gateway.auth_token = Some("a_very_long_auth_token_string_here_for_testing".into());
578        cfg.gateway.request_timeout_secs = 301;
579        let findings = audit_config(&cfg);
580        assert!(findings
581            .iter()
582            .any(|f| f.code == "gateway_timeout_high" && f.severity == Severity::Warn));
583    }
584
585    #[test]
586    fn audit_flags_origins_unrestricted() {
587        let mut cfg = Config::default_config();
588        cfg.gateway.bind = "0.0.0.0:8080".into();
589        cfg.gateway.auth_token = Some("a_very_long_auth_token_string_here_for_testing".into());
590        cfg.gateway.allowed_origins = vec![];
591        let findings = audit_config(&cfg);
592        assert!(findings
593            .iter()
594            .any(|f| f.code == "gateway_origins_unrestricted" && f.severity == Severity::Warn));
595    }
596
597    #[test]
598    fn render_doctor_report_marks_soft_warn_and_fail() {
599        let report = DoctorReport {
600            findings: vec![],
601            checks: vec![
602                Check {
603                    name: "passing".into(),
604                    ok: true,
605                    detail: "all good".into(),
606                    soft_warn: false,
607                },
608                Check {
609                    name: "optional missing".into(),
610                    ok: false,
611                    detail: "not on PATH (optional)".into(),
612                    soft_warn: true,
613                },
614                Check {
615                    name: "required missing".into(),
616                    ok: false,
617                    detail: "not found".into(),
618                    soft_warn: false,
619                },
620            ],
621        };
622        let rendered = render_doctor_report(&report);
623        assert!(rendered.contains("- [OK] passing: all good"));
624        assert!(rendered.contains("- [WARN] optional missing: not on PATH (optional)"));
625        assert!(rendered.contains("- [FAIL] required missing: not found"));
626    }
627
628    #[test]
629    fn check_config_file_reports_missing_path() {
630        let path = "/tmp/apollo-doctor-missing-config-test-xyz123.json";
631        let check = check_config_file(path);
632        assert!(!check.ok);
633        assert!(!check.soft_warn);
634        assert_eq!(check.name, "Config file");
635        assert!(check.detail.contains("not found"));
636    }
637
638    #[test]
639    fn check_config_file_reports_valid_config() {
640        let dir = tempfile::tempdir().unwrap();
641        let path = dir.path().join("apollo.json");
642        std::fs::write(&path, "{}").unwrap();
643        let check = check_config_file(path.to_str().unwrap());
644        assert!(check.ok);
645        assert!(!check.soft_warn);
646        assert!(check.detail.contains("parses OK"));
647    }
648}