Skip to main content

safe_chains/targets/
cursor.rs

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!(
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}
76
77impl HookFormat for CursorHookFormat {
78    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
79        let envelope: CursorHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError {
80            message: e.to_string(),
81        })?;
82        Ok(HookInput {
83            command: envelope.command,
84            cwd: envelope.cwd,
85        })
86    }
87
88    fn render_response(&self, verdict: Verdict) -> HookResponse {
89        if verdict.is_allowed() {
90            let reason = allow_reason(verdict);
91            let body = json!({
92                "permission": "allow",
93                "agent_message": reason,
94            });
95            HookResponse {
96                stdout: serde_json::to_string(&body).unwrap_or_default(),
97                exit_code: 0,
98            }
99        } else {
100            HookResponse {
101                stdout: String::new(),
102                exit_code: 0,
103            }
104        }
105    }
106}
107
108fn hook_entry(binary: &str) -> Value {
109    json!({
110        "command": binary,
111        "timeout": 30,
112    })
113}
114
115fn has_safe_chains_hook(settings: &Value) -> bool {
116    settings
117        .get("hooks")
118        .and_then(|h| h.get("beforeShellExecution"))
119        .and_then(|arr| arr.as_array())
120        .is_some_and(|entries| {
121            entries.iter().any(|entry| {
122                entry
123                    .get("command")
124                    .and_then(|c| c.as_str())
125                    .is_some_and(|cmd| cmd.contains("safe-chains"))
126            })
127        })
128}
129
130fn add_hook(settings: &mut Value, binary: &str) {
131    if !settings.is_object() {
132        *settings = json!({"version": 1});
133    }
134    let Some(obj) = settings.as_object_mut() else {
135        unreachable!("settings was just set to an object");
136    };
137    if !obj.contains_key("version") {
138        obj.insert("version".to_string(), json!(1));
139    }
140    let hooks = obj
141        .entry("hooks")
142        .or_insert_with(|| json!({}))
143        .as_object_mut()
144        .expect("hooks key was created above as an object");
145    let before_shell = hooks
146        .entry("beforeShellExecution")
147        .or_insert_with(|| json!([]))
148        .as_array_mut()
149        .expect("beforeShellExecution was created above as an array");
150    before_shell.push(hook_entry(binary));
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::verdict::SafetyLevel;
157
158    fn target() -> CursorTarget {
159        CursorTarget
160    }
161
162    #[test]
163    fn install_no_cursor_dir_skips() {
164        let dir = tempfile::tempdir().unwrap();
165        let outcome = target().install(dir.path()).unwrap();
166        assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
167    }
168
169    #[test]
170    fn install_creates_hooks_file() {
171        let dir = tempfile::tempdir().unwrap();
172        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
173        let outcome = target().install(dir.path()).unwrap();
174        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
175        let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
176        let settings: Value = serde_json::from_str(&contents).unwrap();
177        assert_eq!(settings.get("version").and_then(|v| v.as_u64()), Some(1));
178        assert!(has_safe_chains_hook(&settings));
179    }
180
181    #[test]
182    fn install_uses_subcommand_invocation() {
183        let dir = tempfile::tempdir().unwrap();
184        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
185        target().install(dir.path()).unwrap();
186        let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
187        assert!(contents.contains("safe-chains hook cursor"));
188    }
189
190    #[test]
191    fn install_idempotent() {
192        let dir = tempfile::tempdir().unwrap();
193        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
194        target().install(dir.path()).unwrap();
195        let outcome = target().install(dir.path()).unwrap();
196        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
197    }
198
199    #[test]
200    fn install_preserves_existing_hooks() {
201        let dir = tempfile::tempdir().unwrap();
202        let cursor_dir = dir.path().join(".cursor");
203        std::fs::create_dir(&cursor_dir).unwrap();
204        std::fs::write(
205            cursor_dir.join("hooks.json"),
206            r#"{"version": 1, "hooks": {"afterFileEdit": [{"command": "format-it", "timeout": 30}]}}"#,
207        )
208        .unwrap();
209        target().install(dir.path()).unwrap();
210        let contents = std::fs::read_to_string(cursor_dir.join("hooks.json")).unwrap();
211        let settings: Value = serde_json::from_str(&contents).unwrap();
212        assert!(has_safe_chains_hook(&settings));
213        assert!(
214            settings
215                .pointer("/hooks/afterFileEdit")
216                .and_then(|a| a.as_array())
217                .is_some_and(|a| !a.is_empty()),
218            "existing afterFileEdit hook must be preserved"
219        );
220    }
221
222    /// Verbatim sample payload from cursor.com/docs/hooks for the
223    /// `beforeShellExecution` event. Bash command is at top level
224    /// (not nested in tool_input as Claude/Codex do).
225    const CURSOR_DOCS_SAMPLE: &str = r#"{
226        "conversation_id": "abc-123",
227        "generation_id": "gen-456",
228        "model": "claude-sonnet-4-5",
229        "hook_event_name": "beforeShellExecution",
230        "cursor_version": "2.0.43",
231        "workspace_roots": ["/Users/me/project"],
232        "user_email": "me@example.com",
233        "transcript_path": "/Users/me/.cursor/transcripts/abc.json",
234        "command": "ls -la",
235        "cwd": "/Users/me/project",
236        "sandbox": false
237    }"#;
238
239    #[test]
240    fn parse_input_extracts_top_level_command() {
241        let parsed = CursorHookFormat.parse_input(CURSOR_DOCS_SAMPLE).unwrap();
242        assert_eq!(parsed.command, "ls -la");
243        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
244    }
245
246    #[test]
247    fn parse_input_rejects_garbage() {
248        assert!(CursorHookFormat.parse_input("not json").is_err());
249        assert!(CursorHookFormat.parse_input("{}").is_err());
250    }
251
252    #[test]
253    fn render_response_uses_permission_key_not_decision() {
254        // Cursor's contract is `permission`, NOT `decision` /
255        // `permissionDecision`. Wiring this wrong is silently fail-
256        // open per their failure semantics — tested explicitly.
257        let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
258        let v: Value = serde_json::from_str(&r.stdout).unwrap();
259        assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("allow"));
260        assert!(v.get("decision").is_none());
261        assert!(v.get("permissionDecision").is_none());
262    }
263
264    #[test]
265    fn render_response_includes_agent_message() {
266        let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
267        let v: Value = serde_json::from_str(&r.stdout).unwrap();
268        assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
269    }
270
271    #[test]
272    fn render_response_deny_emits_empty_body() {
273        let r = CursorHookFormat.render_response(Verdict::Denied);
274        assert_eq!(r.stdout, "");
275    }
276}