openmoat 0.5.0

Policy checks for AI coding agents: decides allow, defer, 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.
use std::path::Path;

use openmoat_hosts::Host;

use super::hook::Reply;
use super::hosts::{self, Answer, Place};
use super::scenario::{CATEGORIES, Call, scenarios};

fn exited(code: i32, stdout: &str, stderr: &str) -> Reply {
    Reply::Exited {
        code: Some(code),
        stdout: stdout.to_owned(),
        stderr: stderr.to_owned(),
    }
}

fn shell() -> Call {
    Call::Shell("ls".to_owned())
}

fn pre_tool_use(decision: &str) -> String {
    format!(
        r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"{decision}"}}}}"#
    )
}

#[test]
fn claude_code_reads_permission_decision_legacy_decision_and_exit_2() {
    let read = |reply: &Reply| hosts::classify(Host::ClaudeCode, &shell(), reply);
    assert_eq!(read(&exited(0, &pre_tool_use("allow"), "")), Answer::Allow);
    assert_eq!(read(&exited(0, &pre_tool_use("ask"), "")), Answer::Ask);
    assert_eq!(read(&exited(0, &pre_tool_use("deny"), "")), Answer::Deny);
    assert_eq!(
        read(&exited(0, r#"{"decision":"block"}"#, "")),
        Answer::Deny
    );
    assert_eq!(
        read(&exited(0, r#"{"decision":"approve"}"#, "")),
        Answer::Allow
    );
    assert_eq!(read(&exited(2, "", "")), Answer::Deny);
    assert_eq!(read(&exited(0, "", "")), Answer::Passthrough);
    assert_eq!(read(&exited(0, "not json", "")), Answer::Passthrough);
    assert_eq!(read(&exited(1, &pre_tool_use("deny"), "")), Answer::Error);
    assert_eq!(read(&Reply::TimedOut), Answer::Error);
    assert_eq!(read(&Reply::NotStarted("gone".into())), Answer::Error);
}

#[test]
fn codex_cannot_ask_and_blocks_on_exit_2_only_with_a_reason() {
    let read = |reply: &Reply| hosts::classify(Host::Codex, &shell(), reply);
    assert_eq!(read(&exited(0, &pre_tool_use("allow"), "")), Answer::Allow);
    assert_eq!(
        read(&exited(0, &pre_tool_use("ask"), "")),
        Answer::Passthrough
    );
    assert_eq!(read(&exited(0, &pre_tool_use("deny"), "")), Answer::Deny);
    assert_eq!(read(&exited(2, "", "blocked")), Answer::Deny);
    assert_eq!(read(&exited(2, "", " \n")), Answer::Error);
    assert_eq!(read(&exited(0, "", "")), Answer::Passthrough);
    assert_eq!(read(&exited(101, "", "panic")), Answer::Error);
}

#[test]
fn cursor_reads_permission_and_asks_only_for_shell_and_mcp() {
    let read = |call: &Call, reply: &Reply| hosts::classify(Host::Cursor, call, reply);
    let permission = |word: &str| exited(0, &format!(r#"{{"permission":"{word}"}}"#), "");
    let file = Call::Write("a".to_owned());
    let read_file = Call::Read("a".to_owned());
    assert_eq!(read(&shell(), &permission("allow")), Answer::Allow);
    assert_eq!(read(&shell(), &permission("ask")), Answer::Ask);
    assert_eq!(read(&shell(), &permission("deny")), Answer::Deny);
    assert_eq!(read(&file, &permission("ask")), Answer::Passthrough);
    assert_eq!(read(&read_file, &permission("ask")), Answer::Error);
    assert_eq!(read(&shell(), &exited(2, "", "")), Answer::Deny);
    assert_eq!(read(&shell(), &exited(0, "", "")), Answer::Error);
    assert_eq!(read(&shell(), &exited(1, "", "")), Answer::Error);
}

#[test]
fn an_ask_reaches_hosts_that_cannot_ask_as_a_deny() {
    use openmoat_core::Verdict;
    let file = Call::Edit("a".to_owned());
    assert_eq!(
        hosts::seen(Host::ClaudeCode, &shell(), Verdict::Ask),
        Answer::Ask
    );
    assert_eq!(
        hosts::seen(Host::Codex, &shell(), Verdict::Ask),
        Answer::Deny
    );
    assert_eq!(
        hosts::seen(Host::Cursor, &shell(), Verdict::Ask),
        Answer::Ask
    );
    assert_eq!(hosts::seen(Host::Cursor, &file, Verdict::Ask), Answer::Deny);
    assert!(Answer::Passthrough.meets(Answer::Allow));
    assert!(!Answer::Passthrough.meets(Answer::Deny));
    assert!(!Answer::Error.meets(Answer::Allow));
}

#[test]
fn every_scenario_file_is_bundled_valid_and_runs_on_some_host() {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("moatbench");
    let mut files: Vec<String> = std::fs::read_dir(dir)
        .unwrap()
        .map(|e| e.unwrap().path())
        .filter(|p| p.extension().is_some_and(|x| x == "yaml"))
        .map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
        .collect();
    files.sort();
    let bundled: Vec<_> = CATEGORIES.iter().map(|(c, _)| (*c).to_owned()).collect();
    assert_eq!(files, bundled, "add new scenario files to CATEGORIES");
    let at = Place {
        home: Path::new("/home/u"),
        project: Path::new("/home/u/proj"),
        session: "s",
        call_id: "c",
    };
    for (_, scenario) in scenarios().unwrap() {
        let runs = Host::ALL.iter().any(|&host| {
            scenario
                .steps
                .iter()
                .all(|step| hosts::payload(host, &step.call().unwrap(), &at).is_some())
        });
        assert!(runs, "{}: no host can run it", scenario.id);
    }
}