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

use super::*;
use crate::sandbox::{Grants, landlock};

fn shown(path: &str) -> Grant {
    Grant {
        path: path.to_owned(),
        shown: true,
    }
}

fn empty(path: &str) -> Grant {
    Grant {
        path: path.to_owned(),
        shown: false,
    }
}

#[test]
fn within_ignores_trailing_slashes_and_needs_whole_components() {
    assert!(within("/tmp/x/.moat", "/tmp", false));
    assert!(within("/tmp/x/.moat/", "/tmp/", false));
    assert!(within("/tmp", "/tmp/", false));
    assert!(within("/tmp/", "/tmp", false));
    assert!(within("/anything", "/", false));
    assert!(within("/", "/", false));
    assert!(!within("/tmpfoo/.moat", "/tmp", false));
    assert!(!within("/tmp", "/tmp/x", false));
    assert!(!within("/", "/tmp", false));
}

#[test]
fn within_follows_the_file_systems_case() {
    assert!(
        !within("/Proj/.moat", "/proj", false),
        "Linux: a different path"
    );
    assert!(
        within("/Proj/.moat", "/proj", true),
        "macOS, Windows: the same"
    );
    assert!(within("/proj/STATE", "/PROJ/", true));
}

#[test]
fn a_tree_holding_the_state_directory_exposes_it() {
    let grants = [shown("/usr"), shown("/tmp"), shown("/home/me/proj")];
    assert_eq!(
        exposing("/tmp/ci/.moat", &grants, false),
        Some(Exposure::Inside("/tmp".into()))
    );
    assert_eq!(
        exposing("/home/me/proj/state/", &grants, false),
        Some(Exposure::Inside("/home/me/proj".into()))
    );
    assert_eq!(exposing("/home/me/.moat", &grants, false), None);
    assert_eq!(exposing("/home/me/projects/.moat", &grants, false), None);
}

#[test]
fn a_grant_inside_the_state_directory_exposes_it() {
    let grants = [shown("/usr"), shown("/home/me/.moat/mcp")];
    assert_eq!(
        exposing("/home/me/.moat", &grants, false),
        Some(Exposure::Holds("/home/me/.moat/mcp".into()))
    );
    let itself = [shown("/home/me/.moat/")];
    assert_eq!(
        exposing("/home/me/.moat", &itself, false),
        Some(Exposure::Inside("/home/me/.moat/".into()))
    );
}

#[test]
fn an_empty_tmpfs_hides_it_unless_something_is_bound_over_it() {
    // `--isolate`: the read root `/tmp` and the temp directory `/tmp` are one
    // `tmpfs`; a temp directory deeper than a shown read root hides it too.
    let same = [shown("/tmp"), empty("/tmp")];
    assert_eq!(exposing("/tmp/ci/.moat", &same, false), None);
    let deeper = [shown("/tmp"), empty("/tmp/user")];
    assert_eq!(exposing("/tmp/user/.moat", &deeper, false), None);
    assert_eq!(
        exposing("/tmp/other/.moat", &deeper, false),
        Some(Exposure::Inside("/tmp".into()))
    );
    let bound = [empty("/tmp"), shown("/tmp/ci/.moat/mcp")];
    assert_eq!(
        exposing("/tmp/ci/.moat", &bound, false),
        Some(Exposure::Holds("/tmp/ci/.moat/mcp".into()))
    );
}

#[test]
fn grants_are_compared_with_their_symlinks_resolved() {
    let dir = tempfile::tempdir().unwrap();
    let real = dir.path().join("real");
    std::fs::create_dir_all(real.join("state")).unwrap();
    let link = dir.path().join("link");
    std::os::unix::fs::symlink(&real, &link).unwrap();
    let rules = Rules {
        read: vec![path_string(&link)],
        ..Rules::default()
    };
    // The state directory spelled through the link, the grant through the
    // target, or the other way round: one tree.
    let (state, exposure) = find(&link.join("state"), &rules, None).unwrap();
    let real = resolved(&real);
    assert_eq!(state, format!("{real}/state"));
    assert_eq!(exposure, Exposure::Inside(real.clone()));
    let rules = Rules {
        read: vec![real.clone()],
        ..Rules::default()
    };
    assert!(find(&link.join("state"), &rules, None).is_some());
    assert!(find(&dir.path().join("elsewhere"), &rules, None).is_none());
}

fn default_rules(moved_dirs: Vec<(String, String)>) -> Rules {
    let policy = Policy::parse(DEFAULT_POLICY).unwrap();
    let ctx = EvalContext {
        home: "/home/me".into(),
        project: Some("/home/me/proj".into()),
        real_home: None,
        real_project: None,
        moved_dirs,
        cwd: "/home/me/proj".into(),
        case_insensitive_paths: false,
    };
    let grants = Grants {
        proxy_port: Some(18080),
        tmpdir: Some("/tmp".into()),
        program: Some("/home/me/.local/bin/agent".into()),
        writes: Vec::new(),
    };
    let ir = openmoat_core::ir::lower(&policy, &ctx).unwrap();
    landlock::generate(&ir, &grants).unwrap().rules
}

/// The common case: `MOAT_HOME` unset is `~/.moat`, and no default read root
/// is the home directory or holds it, so a normal user's `moat run` starts.
#[test]
fn the_default_state_directory_is_outside_the_default_grants() {
    let rules = default_rules(Vec::new());
    let grants: Vec<Grant> = rules
        .read
        .iter()
        .chain(&rules.write)
        .map(|p| shown(p))
        .collect();
    assert_eq!(exposing("/home/me/.moat", &grants, false), None);
    // Moved into the project or the temp directory, it is exposed.
    let moved = |dir: &str| vec![("~/.moat".to_owned(), dir.to_owned())];
    for (dir, root) in [("/home/me/proj/state", "/home/me/proj"), ("/tmp/m", "/tmp")] {
        let rules = default_rules(moved(dir));
        let grants: Vec<Grant> = rules
            .read
            .iter()
            .chain(&rules.write)
            .map(|p| shown(p))
            .collect();
        assert_eq!(
            exposing(dir, &grants, false),
            Some(Exposure::Inside(root.into())),
            "{dir}"
        );
    }
    // Moved elsewhere, it is not.
    let rules = default_rules(moved("/srv/moat"));
    let grants: Vec<Grant> = rules
        .read
        .iter()
        .chain(&rules.write)
        .map(|p| shown(p))
        .collect();
    assert_eq!(exposing("/srv/moat", &grants, false), None);
}

#[test]
fn the_refusal_names_the_root_and_offers_isolate_only_where_it_hides() {
    let dir = tempfile::tempdir().unwrap();
    let tmp = resolved(dir.path());
    let project = format!("{tmp}/proj");
    std::fs::create_dir_all(format!("{project}/state")).unwrap();
    let rules = Rules {
        read: vec!["/usr".into()],
        write: vec![tmp.clone(), project.clone()],
        connect_port: None,
    };
    let in_project = Path::new(&project).join("state");
    let message = refusal(&in_project, &rules, Some(&tmp), false).unwrap();
    assert!(
        message.contains(&format!("Move MOAT_HOME out of {project}")),
        "{message}"
    );
    assert!(!message.contains("--isolate"), "{message}");
    assert!(refusal(&in_project, &rules, Some(&tmp), true).is_some());
    // Directly in the temp directory: `--isolate` covers it with a `tmpfs`.
    let in_tmp = dir.path().join("moat");
    let message = refusal(&in_tmp, &rules, Some(&tmp), false).unwrap();
    assert!(
        message.contains(&format!("Move MOAT_HOME out of {tmp}")),
        "{message}"
    );
    assert!(message.contains("`moat run --isolate`"), "{message}");
    assert_eq!(refusal(&in_tmp, &rules, Some(&tmp), true), None);
    assert_eq!(
        refusal(Path::new("/nowhere/granted"), &rules, Some(&tmp), false),
        None
    );
}