1use 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}