openmoat 0.1.0-alpha.2

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::{Grants, Overlay};
use crate::cli::{AllowArgs, DoctorArgs};
use crate::exit::Code;
use crate::home::Home;
use crate::install::{HookState, HostConfig};
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(&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, command)) = 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 run \"{command}\" (rule {}, session {})",
        host.display_name(),
        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(&AllowArgs {
        command: Some(command),
        last: false,
        host: (!always).then_some(host),
        session: (!always).then_some(event.session_id),
        always,
        site: None,
        dir: None,
        remove: None,
    })
}

/// Agents whose hook is installed, and today's decisions.
fn health(today: &[Event]) -> Result<String> {
    let binary = crate::install::hook_binary()?;
    let mut protected = Vec::new();
    for host in Host::ALL {
        if matches!(
            HostConfig::for_host(host)?.state(&binary),
            HookState::Installed
        ) {
            protected.push(host.display_name());
        }
    }
    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: {} decisions, {} denied, {} asked",
        today.len(),
        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, String)>> {
    let Some(event) = super::allow::newest_shell_ask(store)?.filter(|e| e.ts_ms >= today) else {
        return Ok(None);
    };
    let Some(Action::Shell { command }) = event.action.clone() else {
        return Ok(None);
    };
    let host: Host = event.host.parse()?;
    let granted =
        Grants::load(&home.grants_path())?.matches(&event.host, &event.session_id, &command, now);
    let pattern = openmoat_core::literal_shell_pattern(&command).ok();
    let permanent = Overlay::load(&home.overlay_path())?
        .allow
        .iter()
        .any(|g| pattern.as_ref().is_some_and(|p| g.shell.contains(p)));
    Ok((!granted && !permanent).then_some((event, host, command)))
}

/// 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())
}