openmoat 0.2.0

Policy guard for AI coding agents: decides allow, ask or deny for every tool call from Claude Code, Codex and Cursor, configures Claude Code's and Codex's own OS sandboxes from the same policy, and keeps a local audit log.
//! `moat` with no subcommand: one health line, then whatever needs a person.
//!
//! The questions are asked only at a terminal; the answers run the same code as
//! `moat doctor --accept` and `moat allow`, checks included. Without a terminal
//! (a hook, a script, an agent's shell) it prints the command to run and changes
//! nothing.

use std::io::{self, BufRead as _, Write as _};

use anyhow::{Context as _, Result};
use openmoat_audit::Event;
use openmoat_core::{Action, Verdict};
use openmoat_hosts::Host;

use crate::approvals::{self, Grants, Overlay};
use crate::cli::DoctorArgs;
use crate::exit::Code;
use crate::home::Home;
use crate::integrity::Lock;

const MS_PER_DAY: i64 = 86_400_000;

pub fn run() -> Result<Code> {
    let home = Home::locate()?;
    let lock_path = home.lock_path();
    if !lock_path.is_file() {
        println!("OpenMoat is not set up here; run `moat init`.");
        return Ok(Code::Usage);
    }
    let person = crate::terminal::interactive();
    let now = crate::time::now_ms();
    let today = now - now.rem_euclid(MS_PER_DAY);
    let store = home.open_audit()?;
    println!("{}", health(&home, &store.since(today, None)?)?);

    let drift = Lock::load(&lock_path)?.verify();
    if !drift.is_empty() {
        for d in &drift {
            println!("✗ lock  {d}");
        }
        if !person {
            println!("A person must accept these at a terminal: `moat doctor --accept`.");
            return Ok(Code::Usage);
        }
        if !matches!(
            answer("Accept these changes? [y/N] ")?.as_str(),
            "y" | "yes"
        ) {
            println!("Nothing changed; every call is denied until a person accepts.");
            println!("Run `moat doctor` to look at the changes.");
            return Ok(Code::Usage);
        }
        let code = super::doctor::run(&DoctorArgs {
            accept: true,
            verbose: false,
        })?;
        if !Lock::load(&lock_path)?.verify().is_empty() {
            return Ok(code);
        }
    }

    let Some((event, host, action)) = pending_ask(&home, &store, today, now)? else {
        println!("Nothing needs you.");
        println!("`moat show` lists recent decisions; `moat --help` lists every command.");
        return Ok(Code::Ok);
    };
    println!(
        "{} asked to {} (rule {}, session {})",
        host.display_name(),
        approvals::describe(&action),
        event.rules.join(", "),
        event.session_id
    );
    if !person {
        println!("A person can allow it at a terminal: `moat allow --last` for this session,");
        println!("`moat allow --last --always` for good.");
        return Ok(Code::Ok);
    }
    let always = match answer("Allow? [o]nce for this session / [a]lways / [n]o ")?.as_str() {
        "o" | "once" => false,
        "a" | "always" => true,
        _ => {
            println!("Nothing changed.");
            return Ok(Code::Ok);
        }
    };
    // The exact ask shown, not `--last`: a newer ask may have arrived meanwhile.
    super::allow::run_for(&event.host, &event.session_id, &action, always)
}

/// Agents whose hook is installed, with their protection level, and today's decisions.
fn health(home: &Home, today: &[Event]) -> Result<String> {
    let binary = crate::install::hook_binary()?;
    let protected: Vec<String> = crate::protection::report(home, &binary)?
        .into_iter()
        .filter(|agent| agent.level.hooked())
        .map(|agent| format!("{} ({})", agent.name, agent.level.label()))
        .collect();
    let agents = if protected.is_empty() {
        "No agent is protected (run `moat init`)".to_owned()
    } else {
        format!("Protecting {}", protected.join(", "))
    };
    let count = |v: Verdict| today.iter().filter(|e| e.verdict == v).count();
    Ok(format!(
        "{agents} · today: {} decision{}, {} denied, {} asked",
        today.len(),
        if today.len() == 1 { "" } else { "s" },
        count(Verdict::Deny),
        count(Verdict::Ask)
    ))
}

/// The ask `moat allow --last` would take, if it is from today and no grant or
/// permanent rule already covers it.
fn pending_ask(
    home: &Home,
    store: &openmoat_audit::Store,
    today: i64,
    now: i64,
) -> Result<Option<(Event, Host, Action)>> {
    let Some(event) = super::allow::newest_ask(store)?.filter(|e| e.ts_ms >= today) else {
        return Ok(None);
    };
    let action = super::allow::asked(&event)?;
    let host: Host = event.host.parse()?;
    let granted =
        Grants::load(&home.grants_path())?.matches(&event.host, &event.session_id, &action, now);
    let permanent = Overlay::load(&home.overlay_path())?.covers(&action);
    Ok((!granted && !permanent).then_some((event, host, action)))
}

/// Ask a question and read one line; end of input reads as an empty answer.
fn answer(question: &str) -> Result<String> {
    print!("{question}");
    io::stdout().flush()?;
    let mut line = String::new();
    io::stdin()
        .lock()
        .read_line(&mut line)
        .context("reading the answer")?;
    Ok(line.trim().to_ascii_lowercase())
}