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.
use openmoat_core::{DEFAULT_POLICY, EvalContext, Policy};

use super::*;
use crate::sandbox::assert_golden;

fn generated(yaml: &str, proxy_port: Option<u16>) -> Generated {
    let grants = Grants {
        proxy_port,
        tmpdir: Some("/private/var/folders/xy/T".into()),
        program: Some("/Users/me/.local/share/agent/1.0/agent".into()),
        writes: vec!["/Users/me/.agent".into()],
    };
    generated_with(yaml, &grants)
}

fn generated_with(yaml: &str, grants: &Grants) -> Generated {
    let policy = Policy::parse(yaml).expect("test policy lints");
    let ctx = EvalContext {
        home: "/Users/me".into(),
        project: Some("/Users/me/proj".into()),
        real_home: None,
        real_project: None,
        moved_dirs: Vec::new(),
        cwd: "/Users/me/proj".into(),
        case_insensitive_paths: true,
    };
    generate(
        &openmoat_core::ir::lower(&policy, &ctx).expect("lowers"),
        grants,
    )
}

/// The profile and report for the default policy, as a reviewer reads them.
#[test]
fn default_policy_matches_the_golden_profile() {
    let out = generated(DEFAULT_POLICY, Some(18080));
    let losses = out
        .report
        .losses
        .iter()
        .map(|l| format!("; stricter: {l}\n"));
    let wider = out
        .report
        .allowances
        .iter()
        .map(|a| format!("; wider: {a}\n"));
    let text = out.profile + &losses.chain(wider).collect::<String>();
    assert_golden("seatbelt-default.sb", &text);
}

#[test]
fn deny_rules_come_after_every_allow_and_keep_their_exceptions() {
    let profile = generated(DEFAULT_POLICY, Some(18080)).profile;
    let first_deny = profile.find("(deny file-").expect("deny rules");
    let last_allow = profile.rfind("(allow file-").expect("allow rules");
    assert!(last_allow < first_deny, "{profile}");
    assert!(profile.contains("(subpath \"/Users/me/.ssh\")"));
    assert!(profile.contains("(literal \"/Users/me/.cargo/credentials.toml\")"));
    let not = profile.find("(require-not").expect("exceptions");
    let example = profile.find(r#"(regex "^(.*/)?\\.env\\.example$")"#);
    assert!(example.is_some_and(|at| at > not), "{profile}");
    assert!(
        profile.contains("(require-not (require-any\n      (subpath \"/Users/me/proj/.git\")"),
        "the project's .git stays unwritable"
    );
}

#[test]
fn the_keychain_is_never_reachable_and_the_proxy_is_the_only_exit() {
    let profile = generated(DEFAULT_POLICY, Some(18080)).profile;
    assert!(profile.contains("(deny mach-lookup\n  (global-name \"com.apple.SecurityServer\")"));
    let deny = profile.find("com.apple.SecurityServer").unwrap_or_default();
    assert_eq!(profile.rfind("com.apple.SecurityServer"), Some(deny));
    for absent in ["dnssd", "trustd", "(allow network*", "(remote ip \"*"] {
        assert!(!profile.contains(absent), "{absent}");
    }
    assert!(profile.ends_with("(allow network-outbound (remote ip \"localhost:18080\"))\n"));
    let closed = generated(DEFAULT_POLICY, None).profile;
    assert!(!closed.contains("network-outbound"), "{closed}");
}

#[test]
fn losses_and_allowances_are_listed() {
    let report = generated(DEFAULT_POLICY, None).report;
    let losses: Vec<String> = report.losses.iter().map(ToString::to_string).collect();
    assert_eq!(losses.len(), 1, "{losses:?}");
    assert!(losses[0].starts_with("net `registries`: "), "{losses:?}");
    let rules: Vec<String> = report
        .allowances
        .iter()
        .map(|a| format!("{} {}", a.kind, a.rule))
        .collect();
    for rule in [
        "fs.read seatbelt.metadata",
        "fs.read seatbelt.platform",
        "fs.write seatbelt.platform",
        "fs.read sandbox.read_roots",
        "fs.write seatbelt.tmpdir",
        "fs.read seatbelt.case",
        "fs.read seatbelt.program",
        "fs.write seatbelt.write",
    ] {
        assert!(rules.contains(&rule.to_owned()), "{rule}: {rules:?}");
    }
}

#[test]
fn run_grants_open_the_program_and_the_written_paths_but_deny_rules_win() {
    let profile = generated(DEFAULT_POLICY, None).profile;
    let program = r#"(allow file-read*
  (literal "/Users/me/.local/share/agent/1.0/agent"))"#;
    let write = profile
        .find("(subpath \"/Users/me/.agent\")")
        .expect("--write");
    assert!(profile.contains(program), "{profile}");
    assert!(write < profile.find("(deny file-").unwrap_or_default());
    let bare = generated_with(DEFAULT_POLICY, &Grants::default());
    for absent in ["seatbelt.program", "seatbelt.write", "seatbelt.tmpdir"] {
        assert!(!bare.profile.contains(absent), "{absent}");
    }
}

#[test]
fn an_allow_default_opens_the_whole_access() {
    let out = generated(
        "version: 1\ndefaults:\n  fs.write: allow\n  net: allow\n",
        None,
    );
    assert!(out.profile.contains("; default\n(allow file-write*)\n"));
    assert!(!out.profile.contains("; default\n(allow file-read*)"));
    assert!(
        out.report.losses.iter().any(|l| l.rule == "default.net"),
        "{:?}",
        out.report.losses
    );
}

#[test]
fn globs_become_anchored_regexes_with_the_engine_semantics() {
    for (glob, re) in [
        ("**/.env", r"^(.*/)?\.env$"),
        ("**/.moat/**", r"^(.*/)?\.moat(/.*)?$"),
        ("/h/**/x/*.txt", r"^/h/(.*/)?x/[^/]*\.txt$"),
        ("**", "^.*$"),
        ("/a/b?{c,d}", r"^/a/b[^/](c|d)$"),
        ("/dev/ttys[0-9]*", "^/dev/ttys[0-9][^/]*$"),
        ("/x/[!a]", "^/x/[^/a]$"),
        ("/x/[a", r"^/x/\[a$"),
        ("/x/a**b", "^/x/a[^/]*[^/]*b$"),
        (r"/x/\*", r"^/x/\*$"),
    ] {
        assert_eq!(regex(glob), re, "{glob}");
    }
    assert_eq!(filter("/h/.ssh/**"), "(subpath \"/h/.ssh\")");
    assert_eq!(filter("/h/.netrc"), "(literal \"/h/.netrc\")");
    assert_eq!(filter("/h/a\"b"), r#"(literal "/h/a\"b")"#);
    assert_eq!(filter("**/.env"), r#"(regex "^(.*/)?\\.env$")"#);
}