use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::model_relay::wire_format::WireFormat;
use super::super::binding::{
AgentBinding, BindingCapabilities, DaemonChannel, EndpointConvention, Enforces, EnvCommand,
FailureMode, LivenessReport, MarkerPlacement, ModelRelayWiring, ProxyDelivery,
};
use crate::core::envelope::alias::{AliasTable, EventTool, McpKeyRule, MultiValueRule};
pub static ALIASES: AliasTable = AliasTable {
agent: "cursor",
binding: "cursor_hook",
renders_binding: None,
tools: &[
("Shell", "Bash"),
("Read", "Read"),
("Write", "Write"),
("Grep", "Grep"),
("Task", "Task"),
],
event_tools: &[EventTool {
event: "beforeShellExecution",
canonical: "Bash",
keys: &["command", "cwd", "sandbox"],
}],
keep: &[("Shell", &["command", "cwd"])],
inputs: &[],
creates: &[],
mcp_rule: McpKeyRule {
server_field: Some("mcp_server_name"),
bare_prefix: Some("MCP:"),
wrapper: None,
joined: false,
json_string_input: true,
},
multi: MultiValueRule::NONE,
};
const EVENT_TYPES: &[&str] = &[
"preToolUse",
"beforeShellExecution",
"beforeMCPExecution",
"beforeSubmitPrompt",
"sessionStart",
"sessionEnd",
"postToolUse",
"postToolUseFailure",
"subagentStop",
"preCompact",
"stop",
];
const DECIDING: &[&str] = &[
"preToolUse",
"beforeShellExecution",
"beforeMCPExecution",
"beforeSubmitPrompt",
];
pub const CURSOR_INTERCEPT_HOSTS: &[&str] = &[
"api2.cursor.sh",
"api5.cursor.sh",
"agent.api5.cursor.sh",
"agentn.api5.cursor.sh",
"agent.us.api5.cursor.sh",
"agentn.us.api5.cursor.sh",
"agent.global.api5.cursor.sh",
"agentn.global.api5.cursor.sh",
];
const CURSOR_IDE_DELIVERY: bool = true;
pub const CURSOR_IDE_HTTP2_FLAG: &str = "cursor.general.disableHttp2";
const CURSOR_COMMANDS: &[EnvCommand] = &[
EnvCommand::plain("cursor-agent"),
EnvCommand {
name: "agent",
resolve_guard: Some("cursor-agent"),
},
];
const CURSOR_DELIVERY_CLI_ONLY: &[ProxyDelivery] = &[ProxyDelivery::EnvFile {
commands: CURSOR_COMMANDS,
defaults: &[],
}];
const CURSOR_DELIVERY_WITH_IDE: &[ProxyDelivery] = &[
ProxyDelivery::EnvFile {
commands: CURSOR_COMMANDS,
defaults: &[],
},
ProxyDelivery::SettingsKey {
file: crate::hooks::cursor::ide_user_settings,
key: "http.proxy",
flags: &[(CURSOR_IDE_HTTP2_FLAG, true)],
},
];
pub struct CursorBinding {
pub root: PathBuf,
pub hooks_path: PathBuf,
}
impl CursorBinding {
pub fn detect() -> Option<Self> {
let root = crate::hooks::cursor::detect()?;
Some(Self {
hooks_path: crate::hooks::cursor::hooks_json_path(&root),
root,
})
}
}
fn cursor_wire_event(event: &str) -> &'static str {
match event {
"preToolUse" | "beforeShellExecution" | "beforeMCPExecution" => "pre_tool_use",
"beforeSubmitPrompt" => "user_prompt_submit",
"sessionStart" => "session_start",
"sessionEnd" => "session_end",
"postToolUse" => "post_tool_use",
"postToolUseFailure" => "post_tool_use_failure",
"subagentStop" => "subagent_stop",
"preCompact" => "pre_compact",
"stop" => "stop",
_ => "unknown",
}
}
impl AgentBinding for CursorBinding {
fn agent_type(&self) -> &'static str {
"cursor"
}
fn aliases(&self) -> &'static AliasTable {
&ALIASES
}
fn display_name(&self) -> &'static str {
"Cursor"
}
fn config_dir(&self) -> PathBuf {
self.root.clone()
}
fn hook_config_path(&self) -> PathBuf {
self.hooks_path.clone()
}
fn hook_event_types(&self) -> &'static [&'static str] {
EVENT_TYPES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
&["preToolUse", "beforeShellExecution", "beforeSubmitPrompt"]
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::OpenlatchDirArg
}
fn liveness(&self) -> LivenessReport {
LivenessReport {
armed: None,
detail: None,
remedy: None,
code: None,
off: false,
pending: false,
}
}
fn build_hook_entry(
&self,
event: &str,
binary: &Path,
_port: u16,
_marker: &OpenlatchMarker,
) -> Value {
let wire = cursor_wire_event(event);
let openlatch_dir = crate::config::openlatch_dir();
let command = format!(
r#""{}" --agent cursor --event {wire} --openlatch-dir "{}""#,
binary.display(),
openlatch_dir.display()
);
let timeout = if DECIDING.contains(&event) { 900 } else { 10 };
json!({ "type": "command", "command": command, "timeout": timeout })
}
fn config_is_machine_global(&self) -> bool {
crate::hooks::cursor::config_is_machine_global()
}
fn capabilities(&self) -> BindingCapabilities {
BindingCapabilities {
expressible: &["allow", "ask", "deny"],
can_mutate_arguments: false,
native_failure_mode: FailureMode::FailOpen,
admin_owned_settings: false,
declares_session_in_request: true,
notice: false,
observes_execution: true,
enforces: Enforces::All,
}
}
fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
Some(ModelRelayWiring {
wire_format: WireFormat::CursorConnectRpc,
endpoint: EndpointConvention::ProxyEnv {
intercept_hosts: CURSOR_INTERCEPT_HOSTS,
delivery: if CURSOR_IDE_DELIVERY {
CURSOR_DELIVERY_WITH_IDE
} else {
CURSOR_DELIVERY_CLI_ONLY
},
h2_hosts: CURSOR_INTERCEPT_HOSTS,
},
install_id_header: "x-openlatch-install-id",
})
}
fn marker_placement(&self) -> MarkerPlacement {
MarkerPlacement::Sidecar
}
fn file_root_defaults(&self) -> &'static [(&'static str, i64)] {
&[("version", 1)]
}
fn byte_identical_restore(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::envelope::known_types::KNOWN_HOOK_EVENT_TYPES;
fn binding() -> CursorBinding {
CursorBinding {
root: PathBuf::from("/home/test/.cursor"),
hooks_path: PathBuf::from("/home/test/.cursor/hooks.json"),
}
}
#[test]
fn every_event_maps_to_a_known_wire_value() {
for event in EVENT_TYPES.iter().chain(DECIDING) {
let wire = cursor_wire_event(event);
assert_ne!(wire, "unknown", "{event} has no wire value");
assert!(
KNOWN_HOOK_EVENT_TYPES.contains(&wire),
"{event} maps to {wire}, which is not a known hook event type"
);
}
}
#[test]
fn every_deciding_event_is_installed_with_its_translator() {
use crate::hook_output::{translate, Verdict};
for event in DECIDING {
assert!(EVENT_TYPES.contains(event), "{event} is not installed");
assert_ne!(
translate("cursor", event, &Verdict::allow()),
crate::hook_output::empty(),
"{event} would answer {{}}, which Cursor reads as a refusal"
);
}
assert!(binding()
.load_bearing_events()
.iter()
.all(|e| DECIDING.contains(e)));
}
#[test]
fn entry_is_flat_with_only_documented_keys() {
let marker = OpenlatchMarker::new("id".into());
for event in EVENT_TYPES.iter().chain(DECIDING) {
let entry =
binding().build_hook_entry(event, Path::new("/bin/openlatch-hook"), 7443, &marker);
let keys: Vec<&String> = entry.as_object().expect("an object").keys().collect();
assert!(
keys.iter()
.all(|k| ["type", "command", "timeout"].contains(&k.as_str())),
"{event}: undocumented key in {entry}"
);
assert_eq!(entry["type"], "command");
let expected = if DECIDING.contains(event) { 900 } else { 10 };
assert_eq!(entry["timeout"], expected, "{event}");
}
}
#[test]
fn command_quotes_both_paths() {
let _dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let marker = OpenlatchMarker::new("id".into());
let bin = Path::new("/Applications/Open Latch/openlatch-hook");
let entry = binding().build_hook_entry("stop", bin, 7443, &marker);
let command = entry["command"].as_str().expect("a command");
let dir = crate::config::openlatch_dir();
assert_eq!(
command,
format!(
r#""{}" --agent cursor --event stop --openlatch-dir "{}""#,
bin.display(),
dir.display()
)
);
assert!(crate::hooks::entry_shape::is_sidecar_owned(
command, "cursor"
));
}
fn proxy_env(
b: &CursorBinding,
) -> (
&'static [&'static str],
&'static [ProxyDelivery],
&'static [&'static str],
) {
match b
.model_relay_wiring()
.expect("Cursor declares its lane")
.endpoint
{
EndpointConvention::ProxyEnv {
intercept_hosts,
delivery,
h2_hosts,
} => (intercept_hosts, delivery, h2_hosts),
other => panic!("expected ProxyEnv, got {other:?}"),
}
}
#[test]
fn cursor_hosts_do_not_overlap_cline() {
let cline = crate::hooks::cline_providers::CLINE_INTERCEPT_HOSTS;
assert!(!CURSOR_INTERCEPT_HOSTS.is_empty());
for host in CURSOR_INTERCEPT_HOSTS {
assert!(
!cline.iter().any(|c| c.eq_ignore_ascii_case(host)),
"{host} is declared by Cursor and Cline"
);
}
}
#[test]
fn cursor_first_host_is_the_probe_target() {
let b = binding();
let (hosts, _, h2) = proxy_env(&b);
assert_eq!(hosts.first(), Some(&"api2.cursor.sh"));
assert_eq!(h2, hosts, "every Cursor host is flagged h2");
let w = b.model_relay_wiring().expect("wired");
assert_eq!(w.wire_format, WireFormat::CursorConnectRpc);
assert!(
w.wire_format.is_captured() && w.wire_format.has_decoder(),
"Cursor traffic is recorded from its mirror decoder (plan 03)"
);
for never in [
"api3.cursor.sh",
"repo42.cursor.sh",
"metrics.cursor.sh",
"api.origin.cursor.com",
] {
assert!(!hosts.contains(&never), "{never} must tunnel untouched");
}
}
#[test]
fn cursor_ide_delivery_absent_when_flag_false() {
let (_, delivery, _) = proxy_env(&binding());
let has_ide = delivery.iter().any(|d| {
matches!(
d,
ProxyDelivery::SettingsKey {
key: "http.proxy",
..
}
)
});
assert_eq!(has_ide, CURSOR_IDE_DELIVERY);
assert_eq!(
delivery.len(),
if CURSOR_IDE_DELIVERY { 2 } else { 1 },
"the CLI wrapper, plus the IDE key only once its proof has passed"
);
match delivery[0] {
ProxyDelivery::EnvFile { commands, defaults } => {
assert_eq!(commands, CURSOR_COMMANDS);
assert!(defaults.is_empty());
}
ref other => panic!("expected the CLI wrapper, got {other:?}"),
}
assert_eq!(
CURSOR_COMMANDS
.iter()
.map(|c| (c.name, c.resolve_guard))
.collect::<Vec<_>>(),
vec![("cursor-agent", None), ("agent", Some("cursor-agent"))]
);
assert!(matches!(
CURSOR_DELIVERY_WITH_IDE[1],
ProxyDelivery::SettingsKey {
key: "http.proxy",
flags: [(CURSOR_IDE_HTTP2_FLAG, true)],
..
}
));
}
#[test]
fn cursor_declares_the_sidecar_and_byte_identical_restore() {
let b = binding();
assert_eq!(b.marker_placement(), MarkerPlacement::Sidecar);
assert_eq!(b.file_root_defaults(), &[("version", 1)]);
assert!(b.byte_identical_restore());
assert!(matches!(b.daemon_channel(), DaemonChannel::OpenlatchDirArg));
}
#[test]
fn capabilities_are_the_pinned_values() {
let c = binding().capabilities();
assert!(c.ask(), "ask is derived from `expressible`, which holds it");
assert!(!c.notice);
assert!(c.observes_execution);
assert_eq!(c.enforces, Enforces::All);
}
#[test]
fn aliases_resolve_the_pinned_rows() {
let shell = ALIASES.resolve(
"Shell",
Some(json!({"command": "ls", "cwd": "/w", "timeout": 5})),
None,
);
assert_eq!((shell.name.as_str(), shell.mapped), ("Bash", true));
assert_eq!(shell.input, Some(json!({"command": "ls", "cwd": "/w"})));
assert_eq!(shell.native, "Shell");
let event = ALIASES
.resolve_event(
"beforeShellExecution",
&json!({"command": "ls", "cwd": "", "sandbox": false}),
)
.expect("an event that is the command");
assert_eq!(event.name, "Bash");
for tool in ["Read", "Write", "Grep", "Task"] {
assert!(ALIASES.resolve(tool, None, None).mapped, "{tool}");
}
let delete = ALIASES.resolve("Delete", Some(json!({"path": "a"})), None);
assert!(!delete.mapped, "Delete is unmapped on purpose");
let wrapped = ALIASES.resolve(
"create_issue",
Some(json!("{\"title\":\"x\"}")),
Some("github"),
);
assert_eq!(wrapped.name, "mcp__github__create_issue");
assert_eq!(wrapped.input, Some(json!({"title": "x"})));
let bare = ALIASES.resolve("MCP:create_issue", None, None);
assert_eq!(bare.name, "mcp__unknown__create_issue");
assert_eq!(ALIASES.binding_id(true), "cursor_hook");
}
}