1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target, allow_reason};
7use crate::verdict::Verdict;
8
9pub struct CursorTarget;
10
11impl Target for CursorTarget {
12 fn name(&self) -> &'static str {
13 "cursor"
14 }
15
16 fn display_name(&self) -> &'static str {
17 "Cursor CLI"
18 }
19
20 fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
21 vec![home.join(".cursor")]
22 }
23
24 fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
25 let dir = home.join(".cursor");
26 if !dir.exists() {
27 return Ok(InstallOutcome::Skipped {
28 reason: format!("~/.cursor not found at {} (Cursor not installed for this user)", dir.display()),
29 });
30 }
31
32 let path = dir.join("hooks.json");
33 let binary = "safe-chains hook cursor";
34
35 if path.exists() {
36 let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
37 let mut settings: Value = serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
38
39 if has_safe_chains_hook(&settings) {
40 return Ok(InstallOutcome::AlreadyConfigured { path });
41 }
42
43 add_hook(&mut settings, binary).map_err(|e| format!("{}: {e}", path.display()))?;
44 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
45 std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
46 Ok(InstallOutcome::Installed { path })
47 } else {
48 let mut settings = json!({"version": 1});
49 add_hook(&mut settings, binary).map_err(|e| format!("{}: {e}", path.display()))?;
50 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
51 std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
52 Ok(InstallOutcome::Installed { path })
53 }
54 }
55
56 fn hook_format(&self) -> Option<&dyn HookFormat> {
57 Some(&CursorHookFormat)
58 }
59}
60
61struct CursorHookFormat;
62
63impl CursorHookFormat {
64 const SHELL_EVENT: &'static str = "beforeShellExecution";
66}
67
68#[derive(Deserialize)]
69struct CursorHookEnvelope {
70 command: String,
71 #[serde(default)]
79 hook_event_name: Option<String>,
80 #[serde(default)]
81 cwd: Option<String>,
82 #[serde(default)]
83 workspace_roots: Vec<String>,
84}
85
86impl HookFormat for CursorHookFormat {
87 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
88 let mut envelope: CursorHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
89 if let Some(event) = envelope.hook_event_name.as_deref()
93 && event != Self::SHELL_EVENT
94 {
95 return Err(ParseError { message: format!("not a shell event: {event}") });
96 }
97 Ok(HookInput {
98 command: envelope.command,
99 cwd: envelope.cwd,
100 root: (!envelope.workspace_roots.is_empty()).then(|| envelope.workspace_roots.swap_remove(0)),
102 session_id: None,
104 })
105 }
106
107 fn decision_pointer(&self) -> &'static str {
108 "/permission" }
110
111 fn render_response(&self, verdict: Verdict) -> HookResponse {
112 if verdict.is_allowed() {
113 let reason = allow_reason(verdict);
114 let body = json!({
119 "permission": "allow",
120 "agent_message": reason,
121 });
122 HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
123 } else {
124 HookResponse { stdout: String::new(), exit_code: 0 }
125 }
126 }
127
128 fn gated_policy(&self) -> super::GatedPolicy {
133 super::GatedPolicy::Deny
134 }
135
136 fn render_deny(&self, reason: &str) -> HookResponse {
137 let body = json!({
138 "permission": "deny",
139 "user_message": reason,
140 "agent_message": reason,
141 });
142 HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
143 }
144}
145
146fn hook_entry(binary: &str) -> Value {
147 json!({
148 "command": binary,
149 "timeout": 30,
150 })
151}
152
153fn has_safe_chains_hook(settings: &Value) -> bool {
154 settings
155 .get("hooks")
156 .and_then(|h| h.get("beforeShellExecution"))
157 .and_then(|arr| arr.as_array())
158 .is_some_and(|entries| {
159 entries
160 .iter()
161 .any(|entry| entry.get("command").and_then(|c| c.as_str()).is_some_and(|cmd| cmd.contains("safe-chains")))
162 })
163}
164
165fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
166 if let Some(obj) = settings.as_object_mut()
173 && !obj.contains_key("version")
174 {
175 obj.insert("version".to_string(), json!(1));
176 }
177 super::append_hook_entry(settings, "hooks", "beforeShellExecution", hook_entry(binary))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::verdict::SafetyLevel;
184
185 fn target() -> CursorTarget {
186 CursorTarget
187 }
188
189 #[test]
190 fn install_no_cursor_dir_skips() {
191 let dir = tempfile::tempdir().unwrap();
192 let outcome = target().install(dir.path()).unwrap();
193 assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
194 }
195
196 #[test]
197 fn install_creates_hooks_file() {
198 let dir = tempfile::tempdir().unwrap();
199 std::fs::create_dir(dir.path().join(".cursor")).unwrap();
200 let outcome = target().install(dir.path()).unwrap();
201 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
202 let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
203 let settings: Value = serde_json::from_str(&contents).unwrap();
204 assert_eq!(settings.get("version").and_then(|v| v.as_u64()), Some(1));
205 assert!(has_safe_chains_hook(&settings));
206 }
207
208 #[test]
209 fn install_uses_subcommand_invocation() {
210 let dir = tempfile::tempdir().unwrap();
211 std::fs::create_dir(dir.path().join(".cursor")).unwrap();
212 target().install(dir.path()).unwrap();
213 let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
214 assert!(contents.contains("safe-chains hook cursor"));
215 }
216
217 #[test]
218 fn install_idempotent() {
219 let dir = tempfile::tempdir().unwrap();
220 std::fs::create_dir(dir.path().join(".cursor")).unwrap();
221 target().install(dir.path()).unwrap();
222 let outcome = target().install(dir.path()).unwrap();
223 assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
224 }
225
226 #[test]
227 fn install_preserves_existing_hooks() {
228 let dir = tempfile::tempdir().unwrap();
229 let cursor_dir = dir.path().join(".cursor");
230 std::fs::create_dir(&cursor_dir).unwrap();
231 std::fs::write(
232 cursor_dir.join("hooks.json"),
233 r#"{"version": 1, "hooks": {"afterFileEdit": [{"command": "format-it", "timeout": 30}]}}"#,
234 )
235 .unwrap();
236 target().install(dir.path()).unwrap();
237 let contents = std::fs::read_to_string(cursor_dir.join("hooks.json")).unwrap();
238 let settings: Value = serde_json::from_str(&contents).unwrap();
239 assert!(has_safe_chains_hook(&settings));
240 assert!(
241 settings.pointer("/hooks/afterFileEdit").and_then(|a| a.as_array()).is_some_and(|a| !a.is_empty()),
242 "existing afterFileEdit hook must be preserved"
243 );
244 }
245
246 const CURSOR_DOCS_SAMPLE: &str = r#"{
250 "conversation_id": "abc-123",
251 "generation_id": "gen-456",
252 "model": "claude-sonnet-4-5",
253 "hook_event_name": "beforeShellExecution",
254 "cursor_version": "2.0.43",
255 "workspace_roots": ["/Users/me/project"],
256 "user_email": "me@example.com",
257 "transcript_path": "/Users/me/.cursor/transcripts/abc.json",
258 "command": "ls -la",
259 "cwd": "/Users/me/project",
260 "sandbox": false
261 }"#;
262
263 #[test]
274 fn parse_input_abstains_on_a_foreign_hook_event() {
275 for event in ["beforeReadFile", "afterFileEdit", "beforeSubmitPrompt", "stop"] {
276 let envelope = format!(r#"{{"hook_event_name":"{event}","command":"rm -rf /","workspace_roots":["/w"]}}"#);
277 assert!(CursorHookFormat.parse_input(&envelope).is_err(), "decided on a {event} envelope");
278 }
279
280 let shell = r#"{"hook_event_name":"beforeShellExecution","command":"ls","workspace_roots":["/w"]}"#;
282 assert_eq!(CursorHookFormat.parse_input(shell).unwrap().command, "ls");
283
284 let no_event = r#"{"command":"ls","workspace_roots":["/w"]}"#;
287 assert_eq!(CursorHookFormat.parse_input(no_event).unwrap().command, "ls");
288 }
289
290 #[test]
291 fn parse_input_extracts_top_level_command() {
292 let parsed = CursorHookFormat.parse_input(CURSOR_DOCS_SAMPLE).unwrap();
293 assert_eq!(parsed.command, "ls -la");
294 assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
295 }
296
297 #[test]
298 fn parse_input_rejects_garbage() {
299 assert!(CursorHookFormat.parse_input("not json").is_err());
300 assert!(CursorHookFormat.parse_input("{}").is_err());
301 }
302
303 #[test]
304 fn parse_input_takes_the_project_root_from_workspace_roots() {
305 let stdin = r#"{"command": "ls", "cwd": "/w/p/sub", "workspace_roots": ["/w/p", "/w/other"]}"#;
306 let parsed = CursorHookFormat.parse_input(stdin).unwrap();
307 assert_eq!(parsed.cwd.as_deref(), Some("/w/p/sub"));
308 assert_eq!(parsed.root.as_deref(), Some("/w/p"), "first workspace root");
309 let bare = CursorHookFormat.parse_input(r#"{"command": "ls"}"#).unwrap();
311 assert_eq!(bare.root, None);
312 }
313
314 #[test]
315 fn render_response_uses_permission_key_not_decision() {
316 let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
320 let v: Value = serde_json::from_str(&r.stdout).unwrap();
321 assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("allow"));
322 assert!(v.get("decision").is_none());
323 assert!(v.get("permissionDecision").is_none());
324 }
325
326 #[test]
327 fn render_response_includes_agent_message() {
328 let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
329 let v: Value = serde_json::from_str(&r.stdout).unwrap();
330 assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
331 }
332
333 #[test]
334 fn render_response_deny_emits_empty_body() {
335 let r = CursorHookFormat.render_response(Verdict::Denied);
337 assert_eq!(r.stdout, "");
338 }
339
340 #[test]
341 fn cursor_is_a_deny_harness() {
342 assert_eq!(CursorHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
345 }
346
347 #[test]
348 fn render_deny_emits_permission_deny_with_message() {
349 let r = CursorHookFormat.render_deny("safe-chains blocked this: not on the allowlist");
350 let v: Value = serde_json::from_str(&r.stdout).unwrap();
351 assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("deny"));
352 assert_eq!(v.get("user_message").and_then(|s| s.as_str()), Some("safe-chains blocked this: not on the allowlist"),);
354 assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
355 assert!(v.get("permissionDecision").is_none());
356 }
357}