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    #[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            // cursor sends the project root(s) in the payload; take the first.
87            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            // cursor-agent (v2026.07.16) IGNORES a hook `permission:"allow"` — its own command
95            // allowlist still prompts (a known bug: forum.cursor.com/t/…/144244, HARNESS-BEHAVIORS
96            // §Cursor). We keep emitting it anyway: it is harmless (cursor just prompts, as it would
97            // on silence) and becomes a real grant the moment cursor honors `allow`.
98            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    // cursor-agent ignores hook `allow` (above) but HONORS `deny` — verified live: a `permission:
115    // "deny"` blocks the command and shows our message. Since `allow` is inert, `deny` is the only
116    // lever that adds protection, so Cursor is a DENY harness (like Codex). Revisit if cursor fixes
117    // `allow`, or if the Cursor IDE differs from the CLI. See HARNESS-BEHAVIORS §Cursor.
118    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    /// Verbatim sample payload from cursor.com/docs/hooks for the
250    /// `beforeShellExecution` event. Bash command is at top level
251    /// (not nested in tool_input as Claude/Codex do).
252    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        // absent workspace_roots → no root
286        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        // Cursor's contract is `permission`, NOT `decision` /
293        // `permissionDecision`. Wiring this wrong is silently fail-
294        // open per their failure semantics — tested explicitly.
295        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        // render_response is only called for ALLOWED verdicts; the Denied branch is defensive.
312        let r = CursorHookFormat.render_response(Verdict::Denied);
313        assert_eq!(r.stdout, "");
314    }
315
316    #[test]
317    fn cursor_is_a_deny_harness() {
318        // cursor-agent honors `deny` (verified live) but ignores `allow`, so gated commands are
319        // VETOED rather than deferred — the only lever that adds protection.
320        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        // Cursor renders `user_message` in the client and passes `agent_message` to the model.
329        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}