safe_chains/targets/
cursor.rs1use 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!(
29 "~/.cursor not found at {} (Cursor not installed for this user)",
30 dir.display()
31 ),
32 });
33 }
34
35 let path = dir.join("hooks.json");
36 let binary = "safe-chains hook cursor";
37
38 if path.exists() {
39 let contents = std::fs::read_to_string(&path)
40 .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
41 let mut settings: Value = serde_json::from_str(&contents)
42 .map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
43
44 if has_safe_chains_hook(&settings) {
45 return Ok(InstallOutcome::AlreadyConfigured { path });
46 }
47
48 add_hook(&mut settings, binary);
49 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
50 std::fs::write(&path, format!("{output}\n"))
51 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
52 Ok(InstallOutcome::Installed { path })
53 } else {
54 let mut settings = json!({"version": 1});
55 add_hook(&mut settings, binary);
56 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
57 std::fs::write(&path, format!("{output}\n"))
58 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
59 Ok(InstallOutcome::Installed { path })
60 }
61 }
62
63 fn hook_format(&self) -> Option<&dyn HookFormat> {
64 Some(&CursorHookFormat)
65 }
66}
67
68struct CursorHookFormat;
69
70#[derive(Deserialize)]
71struct CursorHookEnvelope {
72 command: String,
73 #[serde(default)]
74 cwd: Option<String>,
75 #[serde(default)]
76 workspace_roots: Vec<String>,
77}
78
79impl HookFormat for CursorHookFormat {
80 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
81 let mut envelope: CursorHookEnvelope =
82 serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
83 Ok(HookInput {
84 command: envelope.command,
85 cwd: envelope.cwd,
86 root: (!envelope.workspace_roots.is_empty()).then(|| envelope.workspace_roots.swap_remove(0)),
88 })
89 }
90
91 fn render_response(&self, verdict: Verdict) -> HookResponse {
92 if verdict.is_allowed() {
93 let reason = allow_reason(verdict);
94 let body = json!({
99 "permission": "allow",
100 "agent_message": reason,
101 });
102 HookResponse {
103 stdout: serde_json::to_string(&body).unwrap_or_default(),
104 exit_code: 0,
105 }
106 } else {
107 HookResponse {
108 stdout: String::new(),
109 exit_code: 0,
110 }
111 }
112 }
113
114 fn gated_policy(&self) -> super::GatedPolicy {
119 super::GatedPolicy::Deny
120 }
121
122 fn render_deny(&self, reason: &str) -> HookResponse {
123 let body = json!({
124 "permission": "deny",
125 "user_message": reason,
126 "agent_message": reason,
127 });
128 HookResponse {
129 stdout: serde_json::to_string(&body).unwrap_or_default(),
130 exit_code: 0,
131 }
132 }
133}
134
135fn hook_entry(binary: &str) -> Value {
136 json!({
137 "command": binary,
138 "timeout": 30,
139 })
140}
141
142fn has_safe_chains_hook(settings: &Value) -> bool {
143 settings
144 .get("hooks")
145 .and_then(|h| h.get("beforeShellExecution"))
146 .and_then(|arr| arr.as_array())
147 .is_some_and(|entries| {
148 entries.iter().any(|entry| {
149 entry
150 .get("command")
151 .and_then(|c| c.as_str())
152 .is_some_and(|cmd| cmd.contains("safe-chains"))
153 })
154 })
155}
156
157fn add_hook(settings: &mut Value, binary: &str) {
158 if !settings.is_object() {
159 *settings = json!({"version": 1});
160 }
161 let Some(obj) = settings.as_object_mut() else {
162 unreachable!("settings was just set to an object");
163 };
164 if !obj.contains_key("version") {
165 obj.insert("version".to_string(), json!(1));
166 }
167 let hooks = obj
168 .entry("hooks")
169 .or_insert_with(|| json!({}))
170 .as_object_mut()
171 .expect("hooks key was created above as an object");
172 let before_shell = hooks
173 .entry("beforeShellExecution")
174 .or_insert_with(|| json!([]))
175 .as_array_mut()
176 .expect("beforeShellExecution was created above as an array");
177 before_shell.push(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
242 .pointer("/hooks/afterFileEdit")
243 .and_then(|a| a.as_array())
244 .is_some_and(|a| !a.is_empty()),
245 "existing afterFileEdit hook must be preserved"
246 );
247 }
248
249 const CURSOR_DOCS_SAMPLE: &str = r#"{
253 "conversation_id": "abc-123",
254 "generation_id": "gen-456",
255 "model": "claude-sonnet-4-5",
256 "hook_event_name": "beforeShellExecution",
257 "cursor_version": "2.0.43",
258 "workspace_roots": ["/Users/me/project"],
259 "user_email": "me@example.com",
260 "transcript_path": "/Users/me/.cursor/transcripts/abc.json",
261 "command": "ls -la",
262 "cwd": "/Users/me/project",
263 "sandbox": false
264 }"#;
265
266 #[test]
267 fn parse_input_extracts_top_level_command() {
268 let parsed = CursorHookFormat.parse_input(CURSOR_DOCS_SAMPLE).unwrap();
269 assert_eq!(parsed.command, "ls -la");
270 assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
271 }
272
273 #[test]
274 fn parse_input_rejects_garbage() {
275 assert!(CursorHookFormat.parse_input("not json").is_err());
276 assert!(CursorHookFormat.parse_input("{}").is_err());
277 }
278
279 #[test]
280 fn parse_input_takes_the_project_root_from_workspace_roots() {
281 let stdin = r#"{"command": "ls", "cwd": "/w/p/sub", "workspace_roots": ["/w/p", "/w/other"]}"#;
282 let parsed = CursorHookFormat.parse_input(stdin).unwrap();
283 assert_eq!(parsed.cwd.as_deref(), Some("/w/p/sub"));
284 assert_eq!(parsed.root.as_deref(), Some("/w/p"), "first workspace root");
285 let bare = CursorHookFormat.parse_input(r#"{"command": "ls"}"#).unwrap();
287 assert_eq!(bare.root, None);
288 }
289
290 #[test]
291 fn render_response_uses_permission_key_not_decision() {
292 let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
296 let v: Value = serde_json::from_str(&r.stdout).unwrap();
297 assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("allow"));
298 assert!(v.get("decision").is_none());
299 assert!(v.get("permissionDecision").is_none());
300 }
301
302 #[test]
303 fn render_response_includes_agent_message() {
304 let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
305 let v: Value = serde_json::from_str(&r.stdout).unwrap();
306 assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
307 }
308
309 #[test]
310 fn render_response_deny_emits_empty_body() {
311 let r = CursorHookFormat.render_response(Verdict::Denied);
313 assert_eq!(r.stdout, "");
314 }
315
316 #[test]
317 fn cursor_is_a_deny_harness() {
318 assert_eq!(CursorHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
321 }
322
323 #[test]
324 fn render_deny_emits_permission_deny_with_message() {
325 let r = CursorHookFormat.render_deny("safe-chains blocked this: not on the allowlist");
326 let v: Value = serde_json::from_str(&r.stdout).unwrap();
327 assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("deny"));
328 assert_eq!(
330 v.get("user_message").and_then(|s| s.as_str()),
331 Some("safe-chains blocked this: not on the allowlist"),
332 );
333 assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
334 assert!(v.get("permissionDecision").is_none());
335 }
336}