use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use openmoat_audit::{ChainReport, Store};
use openmoat_core::Policy;
use openmoat_hosts::Host;
use crate::cli::{DoctorArgs, Format};
use crate::exit::Code;
use crate::home::Home;
use crate::install::{HookState, HostConfig, Recorded, dir_variable, env_config_dir, stale_hint};
use crate::integrity::{self, HookPinGap, HookPins, Lock};
use crate::protection;
use crate::render::Deferred;
mod bundle;
mod json;
mod mcp;
mod sandboxes;
mod warnings;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum Area {
State,
Policy,
Lock,
Binary,
Environment,
Hook,
Sandbox,
Audit,
Service,
Mcp,
}
fn service(report: &mut Report, home: &Home) {
if let Some((ok, text)) = super::proxy::service_line(home) {
if ok {
report.line(Area::Service, true, text);
} else {
report.line(Area::Service, false, text);
}
}
}
struct Report {
problems: Vec<(Area, String)>,
out: Deferred,
rows: Vec<json::Row>,
}
impl Report {
fn line(&mut self, area: Area, ok: bool, text: String) {
let _ = writeln!(self.out, "{} {text}", if ok { "✔" } else { "✗" });
let status = if ok {
json::Status::Ok
} else {
json::Status::Problem
};
self.rows.push(json::Row::new(status, Some(area), &text));
if !ok {
self.problems.push((area, text));
}
}
fn note(&mut self, text: &str) {
let _ = writeln!(self.out, "· {text}");
self.rows
.push(json::Row::new(json::Status::Note, None, text));
}
fn warn(&mut self, text: &str) {
let _ = writeln!(self.out, "! {text}");
self.rows
.push(json::Row::new(json::Status::Warning, None, text));
}
}
pub fn run(args: &DoctorArgs) -> Result<Code> {
let home = Home::locate()?;
let binary = crate::install::hook_binary()?;
let as_json = args.format == Format::Json;
let mut report = Report {
problems: Vec::new(),
out: if as_json || args.bundle.is_some() {
Deferred::quiet()
} else {
Deferred::default()
},
rows: Vec::new(),
};
report.line(
Area::State,
home.exists(),
format!("state directory {}", home.root().display()),
);
let policy = policies(&mut report, &home);
if let Some(update) = crate::upgrade::available(&home) {
report.warn(&format!(
"policy update {update}: `openmoat policy upgrade` shows them and asks"
));
}
let lock = load_lock(&mut report, &home);
let drift = lock
.as_ref()
.map(|lock| verify_lock(&mut report, lock, &binary))
.unwrap_or_default();
environment(&mut report, &home);
let hook_files = hooks(&mut report, &home, &binary)?;
if let Some(lock) = &lock {
hook_pins(&mut report, lock, &home, &hook_files);
mcp::lines(&mut report, &home, lock, &binary);
}
if let Some(policy) = &policy {
sandboxes::sandboxes(
&mut report,
&home,
policy,
lock.as_ref(),
&binary,
args.verbose,
);
}
service(&mut report, &home);
let open = warnings::tiocsti_open(&home, &binary)?;
let tiocsti = warnings::tiocsti(open, warnings::legacy_tiocsti().as_deref());
let hangs = warnings::hangs(&home, crate::time::now_ms());
let linux = cfg!(target_os = "linux");
let state = policy
.as_ref()
.and_then(|policy| warnings::state_in_grant(&home, policy, linux));
let macos = warnings::old_macos(crate::os_version::macos().as_deref());
let network = super::run::port_only_network();
let alias = warnings::alias_hooks(&home, &binary);
for warning in [tiocsti, hangs, state, macos, network]
.into_iter()
.flatten()
.chain(alias)
{
report.warn(&warning);
}
report.note(warnings::RENAMED);
for warning in crate::project_settings::warnings_here(&home) {
let name = warning.host.display_name();
report.warn(&format!("{name:<16} project settings {}", warning.text));
}
for agent in protection::report(&home, &binary)? {
if let Some(line) = crate::install::trust_note::protection_line(&agent) {
report.note(&line);
report.note(&format!("{:<16} known gaps: {}", agent.name, agent.gaps));
}
}
match Store::open_read_only(&home.audit_path()).and_then(|store| store.verify_chain()) {
Ok(chain) => audit_line(&mut report, &chain),
Err(e) => report.line(Area::Audit, false, format!("audit log {e}")),
}
if let Some(last) = crate::anchor::Last::load(&home) {
report.note(&format!(
"audit anchor last made {} (event {}) in {}",
crate::time::timestamp(last.anchored_ms),
last.event,
last.to
));
}
if args.accept {
let intact = drift.is_empty() && lock.is_some();
accept(&mut report, &home, &binary, policy.is_some(), intact)?;
}
if let Some(path) = &args.bundle {
return bundle::write(&home, &binary, &report.rows, path, args.bundle_events);
}
if as_json {
return json::finish(&report.rows, report.problems.len());
}
finish(report)
}
fn policies(report: &mut Report, home: &Home) -> Option<Policy> {
let policy = match home.load_policy() {
Ok(policy) => {
let (deny, allow, review) = policy.rule_count();
report.line(
Area::Policy,
true,
format!("policy lints ({deny} deny, {allow} allow, {review} review)"),
);
Some(policy)
}
Err(e) => {
report.line(Area::Policy, false, format!("policy {e:#}"));
None
}
};
match crate::repo::summary(home) {
Ok(Some(line)) => report.note(&format!("repo policy {line}")),
Ok(None) => {}
Err(e) => report.line(
Area::Policy,
false,
format!("repo policy {e:#}; every call in this project is denied"),
),
}
policy
}
fn load_lock(report: &mut Report, home: &Home) -> Option<Lock> {
let lock_path = home.lock_path();
match Lock::load(&lock_path) {
Ok(lock) => Some(lock),
Err(_) if !lock_path.exists() => {
report.line(
Area::Lock,
false,
"lock missing; run `openmoat init`".to_owned(),
);
None
}
Err(e) => {
report.line(Area::Lock, false, format!("lock {e:#}"));
None
}
}
}
fn verify_lock(report: &mut Report, lock: &Lock, binary: &Path) -> Vec<integrity::Drift> {
let drift = lock.verify();
if drift.is_empty() {
report.line(
Area::Lock,
true,
format!(
"lock {} files pinned, all intact",
lock.entries.len()
),
);
}
for d in &drift {
report.line(Area::Lock, false, format!("lock {d}"));
}
if let Some(warning) = warnings::alias_lock(&lock.binary, binary) {
report.warn(&warning);
} else if lock.binary != binary.to_string_lossy() {
report.line(
Area::Binary,
false,
format!(
"binary lock expects {}, running {}; run `openmoat init`",
lock.binary,
binary.display()
),
);
}
drift
}
fn environment(report: &mut Report, home: &Home) {
match crate::environment::Snapshot::load(&home.environment_path()) {
Ok(snapshot) => report.line(
Area::Environment,
true,
format!(
"environment {} dirs, {} programs pinned",
snapshot.path.len(),
snapshot.programs.len()
),
),
Err(e) => report.line(
Area::Environment,
false,
format!("environment {e:#}; run `openmoat init`"),
),
}
}
fn hook_pins(report: &mut Report, lock: &Lock, home: &Home, hook_files: &[PathBuf]) {
let gap = HookPinGap::new(lock, home, hook_files);
for path in gap.unpinned {
report.line(
Area::Hook,
false,
format!(
"hook file {} is not pinned by the lock; run `openmoat init` to pin it",
path.display()
),
);
}
for path in gap.elsewhere {
let (path, moved) = (path.display(), HookPinGap::moved_by());
report.note(&format!(
"hook file {path} is pinned but not this shell's ({moved}); re-pinning keeps it"
));
}
}
fn accept(
report: &mut Report,
home: &Home,
binary: &Path,
policy_lints: bool,
intact: bool,
) -> Result<()> {
crate::terminal::refuse_agent("openmoat doctor --accept")?;
if !crate::terminal::interactive() {
bail!(
"`openmoat doctor --accept` must be run by a person in a terminal, not from a hook or script"
);
}
if !policy_lints {
bail!(
"refusing to pin a policy that does not lint; fix it, then run `openmoat doctor --accept` again"
);
}
if intact {
if home.lock_path().is_file() {
mcp::accept(report, home, binary)?;
}
writeln!(report.out, "nothing else to accept: lock is intact")?;
return Ok(());
}
let lock = integrity::repin(home, binary, HookPins::Keep)?;
writeln!(
report.out,
"✔ lock re-pinned for {} files",
lock.entries.len()
)?;
report
.problems
.retain(|(area, _)| !matches!(area, Area::Lock | Area::Binary));
mcp::accept(report, home, binary)
}
fn finish(mut report: Report) -> Result<Code> {
if report.problems.is_empty() {
writeln!(report.out, "healthy")?;
report.out.finish()?;
return Ok(Code::Ok);
}
writeln!(
report.out,
"{} problem(s). `openmoat init` re-installs and re-pins; `openmoat doctor --accept` accepts edits you made yourself.",
report.problems.len()
)?;
report.out.finish()?;
Ok(Code::Usage)
}
fn hooks(report: &mut Report, home: &Home, binary: &Path) -> Result<Vec<PathBuf>> {
let recorded = Recorded::load(home)?;
let mut hook_files = Vec::new();
crate::install::host_version::prefetch_present(home);
for host in Host::ALL {
let config = HostConfig::for_host(home, host)?;
let name = host.display_name();
if let Some(line) = config
.host_present()
.then(|| crate::install::host_version::advisory(host))
.flatten()
{
report.warn(&line);
}
if let Some((locate, set_up)) = config.custom_location() {
if recorded.dir(host).is_some()
&& let Some(line) = crate::install::location::env_disagrees(locate, &set_up)?
{
report.line(Area::Hook, false, format!("{name:<16} {line}"));
}
} else if let (Some(env), Some(dir)) = (env_config_dir(host)?, recorded.dir(host))
&& env != dir
{
let (var, _) = dir_variable(host)?;
report.line(
Area::Hook,
false,
format!(
"{name:<16} {var} is {}, but `openmoat init` set it up in {}, which OpenMoat uses; \
run `openmoat init` with {var} set to set up that directory instead",
env.display(),
dir.display()
),
);
}
let state = config.state(binary);
if config.host_present()
&& let Some(why) = crate::install::host_version::unsupported(host)
{
report.line(
Area::Hook,
false,
format!("{name:<16} not protected: {why}"),
);
continue;
}
if recorded.skipped(host) && config.host_present() && matches!(state, HookState::Missing) {
report.note(&format!(
"{name:<16} not set up; `openmoat init --hosts {}` adds it",
host.id()
));
continue;
}
match state {
HookState::Installed => {
if let Some(warning) = config.trust_warning() {
report.warn(&format!("{name:<16} {warning}"));
}
hook_files.extend(config.extra_files());
hook_files.push(config.settings_path.clone());
let through = crate::install::host_version::hook_api(host)
.map(|through| format!(" ({through})"))
.unwrap_or_default();
report.line(
Area::Hook,
true,
format!(
"{name:<16} hook installed{through} {}",
config.settings_path.display()
),
);
if let Some(loaded) = crate::heartbeat::check(home, &config) {
let (mark, text) = crate::heartbeat::line(name, &loaded);
let text = format!("{name:<16} {text}");
match mark {
crate::heartbeat::Mark::Note => report.note(&text),
crate::heartbeat::Mark::Warning => report.warn(&text),
}
}
if config.runs_calls_when_hook_fails() {
report.warn(&format!(
"{name:<16} runs a tool call when its hook fails to start, crashes or \
times out: no `claude` 2.1.295 or later on PATH, so the hook has no \
`onFailure: \"block\"`; update Claude Code, then run `openmoat init`"
));
}
}
HookState::Missing if !config.host_present() => {
report.note(&format!("{name:<16} host not found"));
}
HookState::Missing => {
report.line(
Area::Hook,
false,
format!("{name:<16} hook missing; run `openmoat init`"),
);
}
HookState::Stale { command } => {
report.line(
Area::Hook,
false,
format!("{name:<16} {}", stale_hint(&command)),
);
}
HookState::Unreadable(e) | HookState::Unapproved(e) | HookState::FailsOpen(e) => {
report.line(Area::Hook, false, format!("{name:<16} {e}"));
}
}
}
for (host, found) in crate::install::detected()? {
let name = host.display_name();
report.note(&format!(
"{name:<16} {} {}",
host.spec().gaps,
found.display()
));
}
Ok(hook_files)
}
fn audit_line(report: &mut Report, chain: &ChainReport) {
if let Some(broken) = &chain.broken {
report.line(
Area::Audit,
false,
format!(
"audit log hash chain broken at event {}: {}; keep the file as evidence",
broken.id,
broken.kind.describe()
),
);
return;
}
report.line(
Area::Audit,
true,
format!(
"audit log {} events, hash chain intact",
chain.events
),
);
if let Some(head) = &chain.head {
report.note(&format!(
"audit log head {head}; record it elsewhere (`openmoat audit anchor`), then `openmoat audit verify --against` it later"
));
}
if chain.unchained > 0 {
report.note(&format!(
"audit log {} events written before the hash chain existed are not covered",
chain.unchained
));
}
}