openmoat 0.9.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 serde_json::json;

use super::*;

fn warnings(cwd: &Path, user_home: &Path) -> Vec<String> {
    super::warnings(
        cwd,
        user_home,
        Host::ClaudeCode,
        &user_home.join(".claude/settings.json"),
    )
}

/// pi's extension, as `moat init` installs it under `user_home`.
fn pi_extension(user_home: &Path) -> PathBuf {
    user_home.join(".pi/agent/extensions/openmoat.js")
}

/// A trusted Gemini CLI folder's settings that turn hooks off or add one, and
/// a pi project's settings that turn OpenMoat's extension off and its own
/// extensions, are named with the agent that loads them.
#[test]
fn gemini_and_pi_project_settings_and_extensions_are_named() {
    let dir = tempfile::tempdir().unwrap();
    let user_home = dir.path().join("home");
    let project = user_home.join("proj");
    fs::create_dir_all(project.join(".git")).unwrap();
    fs::create_dir_all(project.join(".gemini")).unwrap();
    fs::create_dir_all(project.join(".pi/extensions/helper")).unwrap();
    let gemini = || {
        let ours = user_home.join(".gemini/settings.json");
        super::warnings(&project, &user_home, Host::GeminiCli, &ours)
    };
    let pi = || super::warnings(&project, &user_home, Host::Pi, &pi_extension(&user_home));
    fs::write(
        project.join(".gemini/settings.json"),
        r#"{"ui": {"theme": "x"}}"#,
    )
    .unwrap();
    fs::write(project.join(".pi/settings.json"), r#"{"theme": "dark"}"#).unwrap();
    fs::write(project.join(".pi/extensions/notes.md"), "not code").unwrap();
    assert!(gemini().is_empty());
    assert!(pi().is_empty());

    fs::write(
        project.join(".gemini/settings.json"),
        r#"{"hooksConfig": {"disabled": ["openmoat"]}, "hooks": {"BeforeTool": [{}]}}"#,
    )
    .unwrap();
    let rows = gemini();
    assert_eq!(rows.len(), 1, "{rows:?}");
    assert!(rows[0].contains("`hooksConfig.disabled`"), "{rows:?}");
    assert!(rows[0].contains("`hooks`"), "{rows:?}");
    assert!(rows[0].contains("A Gemini CLI session"), "{rows:?}");

    fs::write(
        project.join(".pi/settings.json"),
        r#"{"extensions": ["!openmoat.js"]}"#,
    )
    .unwrap();
    fs::write(
        project.join(".pi/extensions/evil.ts"),
        "export default () => {}",
    )
    .unwrap();
    fs::write(project.join(".pi/extensions/helper/index.js"), "").unwrap();
    let rows = pi();
    assert_eq!(rows.len(), 3, "{rows:?}");
    assert!(
        rows[0].contains("`!openmoat.js` turns OpenMoat's extension off"),
        "{rows:?}"
    );
    assert!(
        rows[1].contains("evil.ts: is an extension pi loads"),
        "{rows:?}"
    );
    assert!(rows[2].contains("helper: is an extension"), "{rows:?}");
}

/// The person's own settings file turns the hook off in each host's own way.
#[test]
fn a_user_settings_file_that_turns_the_hook_off_is_named() {
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("cfg");
    fs::create_dir_all(&cfg).unwrap();
    let hook = cfg.join("extensions/openmoat.js");
    let off = |host: Host, text: &str| {
        fs::write(cfg.join("settings.json"), text).unwrap();
        switched_off(host, &cfg, &hook)
    };
    for (host, text) in [
        (Host::ClaudeCode, r#"{"disableAllHooks": true}"#),
        (Host::CopilotCli, r#"{"disableAllHooks": true}"#),
        (Host::GeminiCli, r#"{"hooksConfig": {"enabled": false}}"#),
        (Host::Pi, r#"{"extensions": ["-extensions/openmoat.js"]}"#),
        (Host::Pi, "{ not json"),
    ] {
        let found = off(host, text).unwrap_or_else(|| panic!("{host}: {text}"));
        assert!(
            found.starts_with(&cfg.join("settings.json").display().to_string()),
            "{found}"
        );
    }
    for (host, text) in [
        // A project's own concerns are not the person's: hooks and env are theirs.
        (
            Host::ClaudeCode,
            r#"{"hooks": {"Stop": []}, "env": {"A": "b"}, "disableAllHooks": false}"#,
        ),
        (Host::CopilotCli, r#"{"model": "gpt-5"}"#),
        (
            Host::GeminiCli,
            r#"{"hooksConfig": {"disabled": ["lint"]}}"#,
        ),
        (Host::Pi, r#"{"extensions": ["-extensions/other.js"]}"#),
        // Hosts without such a file.
        (Host::Codex, r#"{"disableAllHooks": true}"#),
        (Host::Cursor, r#"{"disableAllHooks": true}"#),
    ] {
        assert_eq!(off(host, text), None, "{host}: {text}");
    }
    fs::remove_file(cfg.join("settings.json")).unwrap();
    assert_eq!(switched_off(Host::Pi, &cfg, &hook), None, "no file");
    // Codex reads `config.toml`, where OpenMoat's own profile sits too.
    let codex = cfg.join("config.toml");
    fs::write(
        &codex,
        "default_permissions = \"moat\"\n[features]\nhooks = false\n",
    )
    .unwrap();
    let found = switched_off(Host::Codex, &cfg, &cfg.join("hooks.json")).unwrap();
    assert!(found.contains("`features.hooks`"), "{found}");
    fs::write(
        &codex,
        "default_permissions = \"moat\"\n[features]\nnetwork_proxy = true\n",
    )
    .unwrap();
    assert_eq!(
        switched_off(Host::Codex, &cfg, &cfg.join("hooks.json")),
        None
    );
}

/// A trusted Codex project's `.codex/` layer and a Cursor project's
/// `.cursor/hooks.json` add hooks beside OpenMoat's.
#[test]
fn codex_and_cursor_project_hooks_are_named() {
    let dir = tempfile::tempdir().unwrap();
    let user_home = dir.path().join("home");
    let project = user_home.join("proj");
    fs::create_dir_all(project.join(".git")).unwrap();
    fs::create_dir_all(project.join(".codex")).unwrap();
    fs::create_dir_all(project.join(".cursor")).unwrap();
    let rows = |host: Host| super::warnings(&project, &user_home, host, Path::new("/x"));
    fs::write(project.join(".codex/config.toml"), "model = \"gpt-5\"\n").unwrap();
    fs::write(
        project.join(".cursor/hooks.json"),
        r#"{"version": 1, "hooks": {}}"#,
    )
    .unwrap();
    assert!(rows(Host::Codex).is_empty());
    assert!(rows(Host::Cursor).is_empty());
    fs::write(
        project.join(".codex/config.toml"),
        "[features]\nhooks = false\n",
    )
    .unwrap();
    fs::write(
        project.join(".codex/hooks.json"),
        r#"{"hooks": {"PreToolUse": [{}]}}"#,
    )
    .unwrap();
    let hooks = r#"{"version": 1, "hooks": {"beforeShellExecution": [{"command": "./x"}]}}"#;
    fs::write(project.join(".cursor/hooks.json"), hooks).unwrap();
    let codex = rows(Host::Codex);
    assert_eq!(codex.len(), 2, "{codex:?}");
    assert!(codex[0].contains("`features.hooks`"), "{codex:?}");
    assert!(
        codex[1].contains("hooks.json: `hooks` adds hooks"),
        "{codex:?}"
    );
    let cursor = rows(Host::Cursor);
    assert_eq!(cursor.len(), 1, "{cursor:?}");
    assert!(cursor[0].contains("A Cursor session"), "{cursor:?}");
    fs::write(project.join(".codex/config.toml"), "[features\n").unwrap();
    assert!(rows(Host::Codex)[0].contains("cannot be checked"));
}

/// A repository's Copilot settings that turn hooks off, and each hook file in
/// its `.github/hooks`, are named with the agent that loads them.
#[test]
fn copilot_repository_settings_and_hook_files_are_named() {
    let dir = tempfile::tempdir().unwrap();
    let user_home = dir.path().join("home");
    let project = user_home.join("proj");
    fs::create_dir_all(project.join(".git")).unwrap();
    fs::create_dir_all(project.join(".github/copilot")).unwrap();
    fs::create_dir_all(project.join(".github/hooks")).unwrap();
    let ours = user_home.join(".copilot/hooks/openmoat.json");
    let copilot = || super::warnings(&project, &user_home, Host::CopilotCli, &ours);
    assert!(copilot().is_empty());
    fs::write(
        project.join(".github/copilot/settings.json"),
        r#"{"model": "gpt-5"}"#,
    )
    .unwrap();
    fs::write(project.join(".github/hooks/notes.txt"), "not a hook").unwrap();
    assert!(
        copilot().is_empty(),
        "a model choice and other files are left alone"
    );
    fs::write(
        project.join(".github/copilot/settings.local.json"),
        r#"{"disableAllHooks": true}"#,
    )
    .unwrap();
    fs::write(project.join(".github/hooks/audit.json"), "{}").unwrap();
    let rows = copilot();
    assert_eq!(rows.len(), 2, "{rows:?}");
    assert!(rows[0].contains("`disableAllHooks`"), "{rows:?}");
    assert!(rows[1].contains("audit.json: adds hooks"), "{rows:?}");
    assert!(
        rows.iter()
            .all(|r| r.contains("A GitHub Copilot CLI session")),
        "{rows:?}"
    );
    assert!(
        warnings(&project, &user_home).is_empty(),
        "not Claude Code's"
    );
}

fn found(value: &Value) -> Vec<String> {
    findings(value.as_object().unwrap())
}

#[test]
fn names_each_key_that_turns_hooks_off_or_runs_a_program() {
    let all = found(&json!({
        "disableAllHooks": true, "hooks": {"Stop": []}, "env": {"A": "b"},
        "apiKeyHelper": "k", "awsAuthRefresh": "r", "awsCredentialExport": "e",
        "otelHeadersHelper": "h", "statusLine": {"command": "s"},
        "subagentStatusLine": {"command": "s"}, "fileSuggestion": {"command": "f"},
        "enabledPlugins": {"p@m": true}, "sandbox": {"enabled": false},
        "extraKnownMarketplaces": {"m": {"source": {"source": "github", "repo": "o/r"}}},
        "enableAllProjectMcpServers": true, "enabledMcpjsonServers": ["db"],
    }));
    assert_eq!(all.len(), 15, "{all:?}");
    assert!(all[0].contains("OpenMoat's included"), "{all:?}");
    assert!(
        all.iter()
            .any(|f| f.contains("`enabledMcpjsonServers` starts MCP servers")),
        "{all:?}"
    );
}

#[test]
fn leaves_permissions_and_empty_or_switched_off_values_alone() {
    assert!(
        found(&json!({
            "permissions": {"allow": ["Bash"], "defaultMode": "acceptEdits"},
            "disableAllHooks": false, "hooks": {}, "env": {}, "apiKeyHelper": "",
            "statusLine": null, "enabledPlugins": {"p@m": false},
            "sandbox": {"enabled": true}, "model": "opus",
            "enableAllProjectMcpServers": false, "enabledMcpjsonServers": [],
            "extraKnownMarketplaces": {}, "disabledMcpjsonServers": ["db"],
        }))
        .is_empty()
    );
}

/// A file that passes the check but is rewritten before the decision is
/// reported, whatever it holds by then; one left as it was passes.
#[test]
fn a_file_rewritten_during_the_check_is_reported() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("settings.json");
    fs::write(&path, r#"{"model": "opus"}"#).unwrap();
    assert_eq!(check_unchanged(&path), None);
    let swapped = check_between(&path, || {
        fs::write(&path, r#"{"disableAllHooks": true}"#).unwrap();
    });
    assert!(swapped.unwrap().contains("changed while"));
    fs::write(&path, "{}").unwrap();
    let touched = check_between(&path, || fs::write(&path, "{ }").unwrap());
    assert!(
        touched.unwrap().contains("changed while"),
        "bytes, not meaning"
    );
    let gone = dir.path().join("gone.json");
    let created = check_between(&gone, || fs::write(&gone, "{}").unwrap());
    assert!(created.unwrap().contains("changed while"), "appeared");
    fs::remove_file(&gone).unwrap();
    assert_eq!(check_unchanged(&gone), None);
    fs::write(&path, r#"{"hooks": {"Stop": [{}]}}"#).unwrap();
    assert!(
        check_between(&path, || panic!("not read again"))
            .unwrap()
            .contains("`hooks`")
    );
}

#[test]
fn reading_tells_absent_from_broken() {
    let dir = tempfile::tempdir().unwrap();
    assert!(read_bytes(&dir.path().join("none.json")).unwrap().is_none());
    assert_eq!(check(&dir.path().join("none.json")), None);
    let path = dir.path().join("settings.json");
    fs::write(&path, "// comment\n{}").unwrap();
    assert!(check(&path).unwrap().starts_with("cannot be checked"));
    fs::write(&path, " ".repeat(usize::try_from(MAX_BYTES).unwrap() + 1)).unwrap();
    assert!(check(&path).unwrap().contains("larger than"));
    fs::write(&path, r#"{"env": {"HTTPS_PROXY": "http://x"}}"#).unwrap();
    assert!(check(&path).unwrap().contains("`env`"));
}

#[test]
fn warns_for_the_session_directory_and_its_project_root_not_the_home() {
    let dir = tempfile::tempdir().unwrap();
    let user_home = dir.path().join("home");
    let project = user_home.join("proj");
    let sub = project.join("sub");
    fs::create_dir_all(project.join(".git")).unwrap();
    fs::create_dir_all(project.join(".claude")).unwrap();
    fs::create_dir_all(sub.join(".claude")).unwrap();
    fs::create_dir_all(user_home.join(".claude")).unwrap();
    fs::write(
        user_home.join(".claude/settings.json"),
        r#"{"hooks": {"Stop": [{}]}}"#,
    )
    .unwrap();
    assert!(warnings(&sub, &user_home).is_empty());
    assert!(warnings(&user_home, &user_home).is_empty(), "user settings");

    fs::write(
        project.join(".claude/settings.json"),
        r#"{"disableAllHooks": true}"#,
    )
    .unwrap();
    fs::write(sub.join(".claude/settings.local.json"), "{").unwrap();
    let rows = warnings(&sub, &user_home);
    assert_eq!(rows.len(), 2, "{rows:?}");
    assert!(
        rows.iter().any(|r| r.contains("`disableAllHooks`")),
        "{rows:?}"
    );
    assert!(
        rows.iter().any(|r| r.contains("cannot be checked")),
        "{rows:?}"
    );
    assert!(rows[0].contains("review the file"), "{rows:?}");
}