openmoat 0.14.0

Policy checks for AI coding agents: decides allow, defer, review or deny for every tool call from Claude Code, Codex, Cursor, OpenClaw, GitHub Copilot CLI, pi and Gemini CLI, configures Claude Code's, Codex's and Cursor's own OS sandboxes from the same policy, and keeps a local audit log.
//! Cursor integration end to end: `init` writes a fail-closed `hooks.json`,
//! `guard --host cursor` answers in Cursor's permission format.

use serde_json::Value;

use crate::common::{DENY, OK, Sandbox, fixture as host_fixture, json, text};

fn sandbox() -> Sandbox {
    Sandbox::installed(&[".cursor"])
}

fn guard(sb: &Sandbox, payload: &str) -> (Option<i32>, Value) {
    let out = sb.guard("cursor", payload);
    (out.status.code(), json(&out))
}

fn fixture(name: &str) -> String {
    host_fixture(&format!("cursor/{name}"))
}

#[test]
fn init_installs_fail_closed_hooks_for_every_cursor_event() {
    let sb = sandbox();
    let root: Value =
        serde_json::from_str(&std::fs::read_to_string(sb.home.join(".cursor/hooks.json")).unwrap())
            .unwrap();
    assert_eq!(root["version"], 1);
    for event in [
        "beforeShellExecution",
        "beforeMCPExecution",
        "beforeReadFile",
        "preToolUse",
    ] {
        let entries = root["hooks"][event]
            .as_array()
            .unwrap_or_else(|| panic!("{event} missing"));
        assert_eq!(entries.len(), 1, "{event}");
        assert_eq!(entries[0]["failClosed"], true, "{event}");
        assert!(
            entries[0]["command"]
                .as_str()
                .unwrap()
                .ends_with("guard --host cursor"),
            "{event}"
        );
    }
}

#[test]
fn shell_exfiltration_is_denied_in_cursor_format() {
    let sb = sandbox();
    let (code, doc) = guard(&sb, &fixture("beforeShellExecution.json"));
    assert_eq!(code, Some(DENY));
    assert_eq!(doc["permission"], "deny");
    let msg = doc["user_message"].as_str().unwrap();
    assert!(msg.contains("secrets-paths"), "{msg}");
    assert_eq!(doc["agent_message"], doc["user_message"]);
}

#[test]
fn a_safe_mcp_tool_is_allowed() {
    let sb = sandbox();
    let (code, doc) = guard(&sb, &fixture("beforeMCPExecution.json"));
    assert_eq!(code, Some(OK));
    assert_eq!(doc["permission"], "allow", "{doc}");
}

#[test]
fn mcp_fetch_to_an_unlisted_host_is_denied() {
    let sb = sandbox();
    let (code, doc) = guard(&sb, &fixture("beforeMCPExecution-fetch.json"));
    assert_eq!(code, Some(DENY));
    assert_eq!(doc["permission"], "deny");
    let msg = doc["agent_message"].to_string();
    assert!(msg.contains("default.net"), "{msg}");
}

#[test]
fn pre_tool_use_grep_of_a_secret_is_denied() {
    let sb = sandbox();
    let payload = serde_json::json!({
        "conversation_id": "c", "hook_event_name": "preToolUse",
        "workspace_roots": [sb.home.to_string_lossy()],
        "tool_name": "Grep",
        "tool_input": {"pattern": "aws_secret", "path": sb.home.join(".aws/credentials").to_string_lossy()},
        "tool_use_id": "tu-10"
    })
    .to_string();
    let (code, doc) = guard(&sb, &payload);
    assert_eq!(code, Some(DENY), "{doc}");
    assert!(
        doc["user_message"]
            .as_str()
            .unwrap()
            .contains("secrets-paths"),
        "{doc}"
    );
}

/// A captured cursor-agent `Grep` names its target with `file_path`, not `path`;
/// reading it as a search of the workspace would let it through.
#[test]
fn pre_tool_use_grep_by_file_path_of_a_secret_is_denied() {
    let sb = sandbox();
    let payload = serde_json::json!({
        "hook_event_name": "preToolUse",
        "workspace_roots": [sb.home.to_string_lossy()],
        "tool_name": "Grep",
        "tool_input": {"file_path": sb.home.join(".aws/credentials").to_string_lossy(), "pattern": "aws_secret"}
    })
    .to_string();
    let (code, doc) = guard(&sb, &payload);
    assert_eq!(code, Some(DENY), "{doc}");
    assert!(
        doc["user_message"].to_string().contains("secrets-paths"),
        "{doc}"
    );
}

/// A tool Cursor does not document is checked against the paths it names
/// instead of passing as ungoverned.
#[test]
fn pre_tool_use_unlisted_search_of_a_secret_is_denied() {
    let sb = sandbox();
    let payload = serde_json::json!({
        "hook_event_name": "preToolUse",
        "workspace_roots": [sb.home.to_string_lossy()],
        "tool_name": "SemanticSearch",
        "tool_input": {"query": "private key", "target_directories": [sb.home.join(".ssh").to_string_lossy()]}
    })
    .to_string();
    let (code, doc) = guard(&sb, &payload);
    assert_eq!(code, Some(DENY), "{doc}");
    assert!(
        doc["user_message"].to_string().contains("secrets-paths"),
        "{doc}"
    );
}

/// A tool outside Cursor's listing and search tools is checked as writing the
/// paths it names, so an editing tool without a write verb in its name cannot
/// change the project's hook file under a read allow.
#[test]
fn pre_tool_use_unlisted_editing_tool_is_checked_as_a_write() {
    let sb = sandbox();
    let project = sb.project();
    let hooks = project.join(".cursor/hooks.json");
    let payload = serde_json::json!({
        "hook_event_name": "preToolUse",
        "workspace_roots": [project.to_string_lossy()],
        "cwd": project.to_string_lossy(),
        "tool_name": "search_replace",
        "tool_input": {"file_path": hooks.to_string_lossy(), "old_string": "a", "new_string": "b"}
    })
    .to_string();
    let (code, doc) = guard(&sb, &payload);
    assert_eq!(code, Some(DENY), "{doc}");
    assert!(
        doc["user_message"].to_string().contains("kernel-self"),
        "{doc}"
    );
}

/// A tool that names no path OpenMoat can check needs review (`tool.unknown`), which
/// Cursor's `preToolUse` receives as a deny, instead of passing as ungoverned;
/// a to-do update (`TodoWrite`) stays ungoverned.
/// A policy that sets `tool.unknown: allow` lets it run.
#[test]
fn pre_tool_use_unknown_tool_without_a_path_needs_review() {
    let sb = sandbox();
    let (code, doc) = guard(&sb, &fixture("preToolUse-unknown.json"));
    assert_eq!(code, Some(DENY), "{doc}");
    let message = doc["user_message"].to_string();
    assert!(message.contains("default.tool.unknown"), "{doc}");
    assert!(message.contains("RunNotebookCell"), "{doc}");

    // A to-do update touches only the agent's own state and stays ungoverned.
    let todo = fixture("preToolUse-unknown.json").replace(
        r#""tool_name": "RunNotebookCell", "tool_input": { "cell_index": 3 }"#,
        r#""tool_name": "TodoWrite", "tool_input": { "todos": [{ "content": "x", "status": "pending" }] }"#,
    );
    assert!(todo.contains("TodoWrite"));
    let (code, doc) = guard(&sb, &todo);
    assert_eq!(code, Some(OK), "{doc}");
    assert_eq!(doc["permission"], "allow", "{doc}");
    assert!(
        doc["user_message"].to_string().contains("ungoverned"),
        "{doc}"
    );

    std::fs::write(
        sb.home.join(".moat/policy.yaml"),
        "version: 1\ndefaults: { '*': review, tool.unknown: allow }\n",
    )
    .unwrap();
    let accept = sb.moat_as_person(&["doctor", "--accept"]);
    assert!(text(&accept).contains("re-pinned"), "{}", text(&accept));
    let (code, doc) = guard(&sb, &fixture("preToolUse-unknown.json"));
    assert_eq!(code, Some(OK), "{doc}");
    assert_eq!(doc["permission"], "allow", "{doc}");
}

fn write_payload(sb: &Sandbox, path: &std::path::Path) -> String {
    let project = sb.project();
    serde_json::json!({
        "conversation_id": "cur-s1", "hook_event_name": "preToolUse",
        "workspace_roots": [project.to_string_lossy()], "cwd": project.to_string_lossy(),
        "tool_name": "Write",
        "tool_input": {"file_path": path.to_string_lossy(), "content": "x"},
        "tool_use_id": "tu-11"
    })
    .to_string()
}

/// Cursor runs a `preToolUse` call answered `"ask"` ("accepted by the schema but
/// not enforced"), so moat sends a deny that says how to approve it, and records
/// the `review`; `moat allow --last` then approves that exact file for the
/// conversation.
#[test]
fn pre_tool_use_review_is_denied_and_allow_last_approves_the_file() {
    let sb = sandbox();
    let notes = sb.home.join("notes").join("todo.md");
    let (code, doc) = guard(&sb, &write_payload(&sb, &notes));
    assert_eq!(code, Some(DENY), "{doc}");
    assert_eq!(doc["permission"], "deny");
    let message = doc["agent_message"].to_string();
    assert!(message.contains("run `openmoat`"), "{doc}");
    assert!(message.contains("openmoat allow --last"), "{doc}");
    let log = text(&sb.moat(&["audit", "export"]));
    assert!(log.contains("\"verdict\":\"review\""), "{log}");

    let allow = sb.moat_approving(&["allow", "--last"]);
    assert_eq!(allow.status.code(), Some(OK), "{}", text(&allow));
    assert!(text(&allow).contains("may write"), "{}", text(&allow));
    let (code, doc) = guard(&sb, &write_payload(&sb, &notes));
    assert_eq!(code, Some(OK), "{doc}");
    assert_eq!(doc["permission"], "allow", "{doc}");
    let other = sb.home.join("notes").join("other.md");
    let (code, _) = guard(&sb, &write_payload(&sb, &other));
    assert_eq!(code, Some(DENY), "only the approved file");
}

/// Cursor prompts on a `beforeShellExecution` `"ask"`, so it keeps the `review`, and
/// `moat allow --last` approves the command for the conversation.
#[test]
fn shell_review_stays_a_review_and_allow_last_approves_it() {
    let sb = sandbox();
    let project = sb.project();
    let payload = serde_json::json!({
        "conversation_id": "cur-s2", "hook_event_name": "beforeShellExecution",
        "workspace_roots": [project.to_string_lossy()], "cwd": project.to_string_lossy(),
        "command": "git config core.pager less"
    })
    .to_string();
    let (code, doc) = guard(&sb, &payload);
    assert_eq!(
        (code, &doc["permission"]),
        (Some(OK), &Value::from("ask")),
        "{doc}"
    );
    let allow = sb.moat_approving(&["allow", "--last"]);
    assert_eq!(allow.status.code(), Some(OK), "{}", text(&allow));
    let (code, doc) = guard(&sb, &payload);
    assert_eq!(
        (code, &doc["permission"]),
        (Some(OK), &Value::from("allow")),
        "{doc}"
    );
}