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).map_err(|e| format!("{}: {e}", path.display()))?;
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).map_err(|e| format!("{}: {e}", path.display()))?;
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            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
89            session_id: None,
90        })
91    }
92
93    fn decision_pointer(&self) -> &'static str {
94        "/permission" // not permissionDecision
95    }
96
97    fn render_response(&self, verdict: Verdict) -> HookResponse {
98        if verdict.is_allowed() {
99            let reason = allow_reason(verdict);
100            // cursor-agent (v2026.07.16) IGNORES a hook `permission:"allow"` — its own command
101            // allowlist still prompts (a known bug: forum.cursor.com/t/…/144244, HARNESS-BEHAVIORS
102            // §Cursor). We keep emitting it anyway: it is harmless (cursor just prompts, as it would
103            // on silence) and becomes a real grant the moment cursor honors `allow`.
104            let body = json!({
105                "permission": "allow",
106                "agent_message": reason,
107            });
108            HookResponse {
109                stdout: serde_json::to_string(&body).unwrap_or_default(),
110                exit_code: 0,
111            }
112        } else {
113            HookResponse {
114                stdout: String::new(),
115                exit_code: 0,
116            }
117        }
118    }
119
120    // cursor-agent ignores hook `allow` (above) but HONORS `deny` — verified live: a `permission:
121    // "deny"` blocks the command and shows our message. Since `allow` is inert, `deny` is the only
122    // lever that adds protection, so Cursor is a DENY harness (like Codex). Revisit if cursor fixes
123    // `allow`, or if the Cursor IDE differs from the CLI. See HARNESS-BEHAVIORS §Cursor.
124    fn gated_policy(&self) -> super::GatedPolicy {
125        super::GatedPolicy::Deny
126    }
127
128    fn render_deny(&self, reason: &str) -> HookResponse {
129        let body = json!({
130            "permission": "deny",
131            "user_message": reason,
132            "agent_message": reason,
133        });
134        HookResponse {
135            stdout: serde_json::to_string(&body).unwrap_or_default(),
136            exit_code: 0,
137        }
138    }
139}
140
141fn hook_entry(binary: &str) -> Value {
142    json!({
143        "command": binary,
144        "timeout": 30,
145    })
146}
147
148fn has_safe_chains_hook(settings: &Value) -> bool {
149    settings
150        .get("hooks")
151        .and_then(|h| h.get("beforeShellExecution"))
152        .and_then(|arr| arr.as_array())
153        .is_some_and(|entries| {
154            entries.iter().any(|entry| {
155                entry
156                    .get("command")
157                    .and_then(|c| c.as_str())
158                    .is_some_and(|cmd| cmd.contains("safe-chains"))
159            })
160        })
161}
162
163fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
164    // cursor's file carries a schema `version` next to the hooks, so it is seeded before the shared
165    // helper runs (the helper only ever creates the hook path itself).
166    if !settings.is_object() {
167        *settings = json!({"version": 1});
168    }
169    if let Some(obj) = settings.as_object_mut()
170        && !obj.contains_key("version")
171    {
172        obj.insert("version".to_string(), json!(1));
173    }
174    super::append_hook_entry(settings, "hooks", "beforeShellExecution", hook_entry(binary))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::verdict::SafetyLevel;
181
182    fn target() -> CursorTarget {
183        CursorTarget
184    }
185
186    #[test]
187    fn install_no_cursor_dir_skips() {
188        let dir = tempfile::tempdir().unwrap();
189        let outcome = target().install(dir.path()).unwrap();
190        assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
191    }
192
193    #[test]
194    fn install_creates_hooks_file() {
195        let dir = tempfile::tempdir().unwrap();
196        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
197        let outcome = target().install(dir.path()).unwrap();
198        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
199        let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
200        let settings: Value = serde_json::from_str(&contents).unwrap();
201        assert_eq!(settings.get("version").and_then(|v| v.as_u64()), Some(1));
202        assert!(has_safe_chains_hook(&settings));
203    }
204
205    #[test]
206    fn install_uses_subcommand_invocation() {
207        let dir = tempfile::tempdir().unwrap();
208        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
209        target().install(dir.path()).unwrap();
210        let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
211        assert!(contents.contains("safe-chains hook cursor"));
212    }
213
214    #[test]
215    fn install_idempotent() {
216        let dir = tempfile::tempdir().unwrap();
217        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
218        target().install(dir.path()).unwrap();
219        let outcome = target().install(dir.path()).unwrap();
220        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
221    }
222
223    #[test]
224    fn install_preserves_existing_hooks() {
225        let dir = tempfile::tempdir().unwrap();
226        let cursor_dir = dir.path().join(".cursor");
227        std::fs::create_dir(&cursor_dir).unwrap();
228        std::fs::write(
229            cursor_dir.join("hooks.json"),
230            r#"{"version": 1, "hooks": {"afterFileEdit": [{"command": "format-it", "timeout": 30}]}}"#,
231        )
232        .unwrap();
233        target().install(dir.path()).unwrap();
234        let contents = std::fs::read_to_string(cursor_dir.join("hooks.json")).unwrap();
235        let settings: Value = serde_json::from_str(&contents).unwrap();
236        assert!(has_safe_chains_hook(&settings));
237        assert!(
238            settings
239                .pointer("/hooks/afterFileEdit")
240                .and_then(|a| a.as_array())
241                .is_some_and(|a| !a.is_empty()),
242            "existing afterFileEdit hook must be preserved"
243        );
244    }
245
246    /// Verbatim sample payload from cursor.com/docs/hooks for the
247    /// `beforeShellExecution` event. Bash command is at top level
248    /// (not nested in tool_input as Claude/Codex do).
249    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]
264    fn parse_input_extracts_top_level_command() {
265        let parsed = CursorHookFormat.parse_input(CURSOR_DOCS_SAMPLE).unwrap();
266        assert_eq!(parsed.command, "ls -la");
267        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
268    }
269
270    #[test]
271    fn parse_input_rejects_garbage() {
272        assert!(CursorHookFormat.parse_input("not json").is_err());
273        assert!(CursorHookFormat.parse_input("{}").is_err());
274    }
275
276    #[test]
277    fn parse_input_takes_the_project_root_from_workspace_roots() {
278        let stdin = r#"{"command": "ls", "cwd": "/w/p/sub", "workspace_roots": ["/w/p", "/w/other"]}"#;
279        let parsed = CursorHookFormat.parse_input(stdin).unwrap();
280        assert_eq!(parsed.cwd.as_deref(), Some("/w/p/sub"));
281        assert_eq!(parsed.root.as_deref(), Some("/w/p"), "first workspace root");
282        // absent workspace_roots → no root
283        let bare = CursorHookFormat.parse_input(r#"{"command": "ls"}"#).unwrap();
284        assert_eq!(bare.root, None);
285    }
286
287    #[test]
288    fn render_response_uses_permission_key_not_decision() {
289        // Cursor's contract is `permission`, NOT `decision` /
290        // `permissionDecision`. Wiring this wrong is silently fail-
291        // open per their failure semantics — tested explicitly.
292        let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
293        let v: Value = serde_json::from_str(&r.stdout).unwrap();
294        assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("allow"));
295        assert!(v.get("decision").is_none());
296        assert!(v.get("permissionDecision").is_none());
297    }
298
299    #[test]
300    fn render_response_includes_agent_message() {
301        let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
302        let v: Value = serde_json::from_str(&r.stdout).unwrap();
303        assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
304    }
305
306    #[test]
307    fn render_response_deny_emits_empty_body() {
308        // render_response is only called for ALLOWED verdicts; the Denied branch is defensive.
309        let r = CursorHookFormat.render_response(Verdict::Denied);
310        assert_eq!(r.stdout, "");
311    }
312
313    #[test]
314    fn cursor_is_a_deny_harness() {
315        // cursor-agent honors `deny` (verified live) but ignores `allow`, so gated commands are
316        // VETOED rather than deferred — the only lever that adds protection.
317        assert_eq!(CursorHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
318    }
319
320    #[test]
321    fn render_deny_emits_permission_deny_with_message() {
322        let r = CursorHookFormat.render_deny("safe-chains blocked this: not on the allowlist");
323        let v: Value = serde_json::from_str(&r.stdout).unwrap();
324        assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("deny"));
325        // Cursor renders `user_message` in the client and passes `agent_message` to the model.
326        assert_eq!(
327            v.get("user_message").and_then(|s| s.as_str()),
328            Some("safe-chains blocked this: not on the allowlist"),
329        );
330        assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
331        assert!(v.get("permissionDecision").is_none());
332    }
333}