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
70impl CursorHookFormat {
71    /// The only cursor hook event that carries a shell command to classify.
72    const SHELL_EVENT: &'static str = "beforeShellExecution";
73}
74
75#[derive(Deserialize)]
76struct CursorHookEnvelope {
77    command: String,
78    /// `hook_event_name` — cursor's shell event is `beforeShellExecution`.
79    ///
80    /// cursor names no TOOL, which is why TODO.md listed it as unable to self-filter. It does name
81    /// the EVENT, which serves the same purpose here: cursor has other hook events
82    /// (`beforeReadFile`, `afterFileEdit`, `beforeSubmitPrompt`, `stop`), and an envelope from one
83    /// of those is not a shell command to classify. The field is in the documented payload and in
84    /// this module's own `CURSOR_DOCS_SAMPLE`; it was simply not deserialized.
85    #[serde(default)]
86    hook_event_name: Option<String>,
87    #[serde(default)]
88    cwd: Option<String>,
89    #[serde(default)]
90    workspace_roots: Vec<String>,
91}
92
93impl HookFormat for CursorHookFormat {
94    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
95        let mut envelope: CursorHookEnvelope =
96            serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
97        // Self-filter on the EVENT, since cursor's payload names no tool. An absent name still
98        // passes: the hook is configured under a specific event, and refusing an envelope that
99        // omits the field would break any version that does not send it.
100        if let Some(event) = envelope.hook_event_name.as_deref()
101            && event != Self::SHELL_EVENT
102        {
103            return Err(ParseError { message: format!("not a shell event: {event}") });
104        }
105        Ok(HookInput {
106            command: envelope.command,
107            cwd: envelope.cwd,
108            // cursor sends the project root(s) in the payload; take the first.
109            root: (!envelope.workspace_roots.is_empty()).then(|| envelope.workspace_roots.swap_remove(0)),
110            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
111            session_id: None,
112        })
113    }
114
115    fn decision_pointer(&self) -> &'static str {
116        "/permission" // not permissionDecision
117    }
118
119    fn render_response(&self, verdict: Verdict) -> HookResponse {
120        if verdict.is_allowed() {
121            let reason = allow_reason(verdict);
122            // cursor-agent (v2026.07.16) IGNORES a hook `permission:"allow"` — its own command
123            // allowlist still prompts (a known bug: forum.cursor.com/t/…/144244, HARNESS-BEHAVIORS
124            // §Cursor). We keep emitting it anyway: it is harmless (cursor just prompts, as it would
125            // on silence) and becomes a real grant the moment cursor honors `allow`.
126            let body = json!({
127                "permission": "allow",
128                "agent_message": reason,
129            });
130            HookResponse {
131                stdout: serde_json::to_string(&body).unwrap_or_default(),
132                exit_code: 0,
133            }
134        } else {
135            HookResponse {
136                stdout: String::new(),
137                exit_code: 0,
138            }
139        }
140    }
141
142    // cursor-agent ignores hook `allow` (above) but HONORS `deny` — verified live: a `permission:
143    // "deny"` blocks the command and shows our message. Since `allow` is inert, `deny` is the only
144    // lever that adds protection, so Cursor is a DENY harness (like Codex). Revisit if cursor fixes
145    // `allow`, or if the Cursor IDE differs from the CLI. See HARNESS-BEHAVIORS §Cursor.
146    fn gated_policy(&self) -> super::GatedPolicy {
147        super::GatedPolicy::Deny
148    }
149
150    fn render_deny(&self, reason: &str) -> HookResponse {
151        let body = json!({
152            "permission": "deny",
153            "user_message": reason,
154            "agent_message": reason,
155        });
156        HookResponse {
157            stdout: serde_json::to_string(&body).unwrap_or_default(),
158            exit_code: 0,
159        }
160    }
161}
162
163fn hook_entry(binary: &str) -> Value {
164    json!({
165        "command": binary,
166        "timeout": 30,
167    })
168}
169
170fn has_safe_chains_hook(settings: &Value) -> bool {
171    settings
172        .get("hooks")
173        .and_then(|h| h.get("beforeShellExecution"))
174        .and_then(|arr| arr.as_array())
175        .is_some_and(|entries| {
176            entries.iter().any(|entry| {
177                entry
178                    .get("command")
179                    .and_then(|c| c.as_str())
180                    .is_some_and(|cmd| cmd.contains("safe-chains"))
181            })
182        })
183}
184
185fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
186    // cursor's file carries a schema `version` next to the hooks, so it is seeded before the shared
187    // helper runs (the helper only ever creates the hook path itself).
188    //
189    // A non-object root is NOT seeded — it used to be replaced with `{"version": 1}`, discarding
190    // whatever was there. The shared helper refuses it below; this only adds the version key to a
191    // file that is already an object.
192    if let Some(obj) = settings.as_object_mut()
193        && !obj.contains_key("version")
194    {
195        obj.insert("version".to_string(), json!(1));
196    }
197    super::append_hook_entry(settings, "hooks", "beforeShellExecution", hook_entry(binary))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::verdict::SafetyLevel;
204
205    fn target() -> CursorTarget {
206        CursorTarget
207    }
208
209    #[test]
210    fn install_no_cursor_dir_skips() {
211        let dir = tempfile::tempdir().unwrap();
212        let outcome = target().install(dir.path()).unwrap();
213        assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
214    }
215
216    #[test]
217    fn install_creates_hooks_file() {
218        let dir = tempfile::tempdir().unwrap();
219        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
220        let outcome = target().install(dir.path()).unwrap();
221        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
222        let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
223        let settings: Value = serde_json::from_str(&contents).unwrap();
224        assert_eq!(settings.get("version").and_then(|v| v.as_u64()), Some(1));
225        assert!(has_safe_chains_hook(&settings));
226    }
227
228    #[test]
229    fn install_uses_subcommand_invocation() {
230        let dir = tempfile::tempdir().unwrap();
231        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
232        target().install(dir.path()).unwrap();
233        let contents = std::fs::read_to_string(dir.path().join(".cursor/hooks.json")).unwrap();
234        assert!(contents.contains("safe-chains hook cursor"));
235    }
236
237    #[test]
238    fn install_idempotent() {
239        let dir = tempfile::tempdir().unwrap();
240        std::fs::create_dir(dir.path().join(".cursor")).unwrap();
241        target().install(dir.path()).unwrap();
242        let outcome = target().install(dir.path()).unwrap();
243        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
244    }
245
246    #[test]
247    fn install_preserves_existing_hooks() {
248        let dir = tempfile::tempdir().unwrap();
249        let cursor_dir = dir.path().join(".cursor");
250        std::fs::create_dir(&cursor_dir).unwrap();
251        std::fs::write(
252            cursor_dir.join("hooks.json"),
253            r#"{"version": 1, "hooks": {"afterFileEdit": [{"command": "format-it", "timeout": 30}]}}"#,
254        )
255        .unwrap();
256        target().install(dir.path()).unwrap();
257        let contents = std::fs::read_to_string(cursor_dir.join("hooks.json")).unwrap();
258        let settings: Value = serde_json::from_str(&contents).unwrap();
259        assert!(has_safe_chains_hook(&settings));
260        assert!(
261            settings
262                .pointer("/hooks/afterFileEdit")
263                .and_then(|a| a.as_array())
264                .is_some_and(|a| !a.is_empty()),
265            "existing afterFileEdit hook must be preserved"
266        );
267    }
268
269    /// Verbatim sample payload from cursor.com/docs/hooks for the
270    /// `beforeShellExecution` event. Bash command is at top level
271    /// (not nested in tool_input as Claude/Codex do).
272    const CURSOR_DOCS_SAMPLE: &str = r#"{
273        "conversation_id": "abc-123",
274        "generation_id": "gen-456",
275        "model": "claude-sonnet-4-5",
276        "hook_event_name": "beforeShellExecution",
277        "cursor_version": "2.0.43",
278        "workspace_roots": ["/Users/me/project"],
279        "user_email": "me@example.com",
280        "transcript_path": "/Users/me/.cursor/transcripts/abc.json",
281        "command": "ls -la",
282        "cwd": "/Users/me/project",
283        "sandbox": false
284    }"#;
285
286    /// cursor abstains on an envelope from one of its OTHER hook events.
287    ///
288    /// `no_target_decides_on_a_foreign_tool` cannot cover this: cursor's payload names no TOOL,
289    /// which is why it was exempted from that guard. It names the EVENT, and cursor has several
290    /// (`beforeReadFile`, `afterFileEdit`, `beforeSubmitPrompt`, `stop`) — an envelope from one of
291    /// those is not a shell command, and classifying whatever `command` field it happens to carry
292    /// would be deciding about something never analysed.
293    ///
294    /// The field was in the documented payload and in `CURSOR_DOCS_SAMPLE` all along; it simply was
295    /// not deserialized, the same oversight that had antigravity listed as unfilterable.
296    #[test]
297    fn parse_input_abstains_on_a_foreign_hook_event() {
298        for event in ["beforeReadFile", "afterFileEdit", "beforeSubmitPrompt", "stop"] {
299            let envelope = format!(
300                r#"{{"hook_event_name":"{event}","command":"rm -rf /","workspace_roots":["/w"]}}"#
301            );
302            assert!(
303                CursorHookFormat.parse_input(&envelope).is_err(),
304                "decided on a {event} envelope"
305            );
306        }
307
308        // The shell event still parses, or "reject everything" would satisfy the above.
309        let shell = r#"{"hook_event_name":"beforeShellExecution","command":"ls","workspace_roots":["/w"]}"#;
310        assert_eq!(CursorHookFormat.parse_input(shell).unwrap().command, "ls");
311
312        // An ABSENT event still parses: the hook is configured under one event, and refusing a
313        // payload that omits the field would break any version that does not send it.
314        let no_event = r#"{"command":"ls","workspace_roots":["/w"]}"#;
315        assert_eq!(CursorHookFormat.parse_input(no_event).unwrap().command, "ls");
316    }
317
318    #[test]
319    fn parse_input_extracts_top_level_command() {
320        let parsed = CursorHookFormat.parse_input(CURSOR_DOCS_SAMPLE).unwrap();
321        assert_eq!(parsed.command, "ls -la");
322        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
323    }
324
325    #[test]
326    fn parse_input_rejects_garbage() {
327        assert!(CursorHookFormat.parse_input("not json").is_err());
328        assert!(CursorHookFormat.parse_input("{}").is_err());
329    }
330
331    #[test]
332    fn parse_input_takes_the_project_root_from_workspace_roots() {
333        let stdin = r#"{"command": "ls", "cwd": "/w/p/sub", "workspace_roots": ["/w/p", "/w/other"]}"#;
334        let parsed = CursorHookFormat.parse_input(stdin).unwrap();
335        assert_eq!(parsed.cwd.as_deref(), Some("/w/p/sub"));
336        assert_eq!(parsed.root.as_deref(), Some("/w/p"), "first workspace root");
337        // absent workspace_roots → no root
338        let bare = CursorHookFormat.parse_input(r#"{"command": "ls"}"#).unwrap();
339        assert_eq!(bare.root, None);
340    }
341
342    #[test]
343    fn render_response_uses_permission_key_not_decision() {
344        // Cursor's contract is `permission`, NOT `decision` /
345        // `permissionDecision`. Wiring this wrong is silently fail-
346        // open per their failure semantics — tested explicitly.
347        let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
348        let v: Value = serde_json::from_str(&r.stdout).unwrap();
349        assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("allow"));
350        assert!(v.get("decision").is_none());
351        assert!(v.get("permissionDecision").is_none());
352    }
353
354    #[test]
355    fn render_response_includes_agent_message() {
356        let r = CursorHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
357        let v: Value = serde_json::from_str(&r.stdout).unwrap();
358        assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
359    }
360
361    #[test]
362    fn render_response_deny_emits_empty_body() {
363        // render_response is only called for ALLOWED verdicts; the Denied branch is defensive.
364        let r = CursorHookFormat.render_response(Verdict::Denied);
365        assert_eq!(r.stdout, "");
366    }
367
368    #[test]
369    fn cursor_is_a_deny_harness() {
370        // cursor-agent honors `deny` (verified live) but ignores `allow`, so gated commands are
371        // VETOED rather than deferred — the only lever that adds protection.
372        assert_eq!(CursorHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
373    }
374
375    #[test]
376    fn render_deny_emits_permission_deny_with_message() {
377        let r = CursorHookFormat.render_deny("safe-chains blocked this: not on the allowlist");
378        let v: Value = serde_json::from_str(&r.stdout).unwrap();
379        assert_eq!(v.get("permission").and_then(|s| s.as_str()), Some("deny"));
380        // Cursor renders `user_message` in the client and passes `agent_message` to the model.
381        assert_eq!(
382            v.get("user_message").and_then(|s| s.as_str()),
383            Some("safe-chains blocked this: not on the allowlist"),
384        );
385        assert!(v.get("agent_message").and_then(|s| s.as_str()).is_some());
386        assert!(v.get("permissionDecision").is_none());
387    }
388}