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}"
);
}
#[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}"
);
}
#[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}"
);
}
#[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}"
);
}
#[test]
fn pre_tool_use_unknown_tool_without_a_path_asks() {
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}");
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: { '*': ask, 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()
}
#[test]
fn pre_tool_use_ask_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, ¬es));
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\":\"ask\""), "{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, ¬es));
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");
}
#[test]
fn shell_ask_stays_an_ask_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}"
);
}